--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 7dfbf057400502f15d371527fc3b56f9a5f8d822
Parents : fe11de8
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-10T07:42:38-05:00
refactor: various fixes and updates
Changes
34 files changed, 2035 insertions(+), 151 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c899cc0..2170113c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -223,7 +223,7 @@ All notable changes to this project will be documented in this file.
- **Banishment**: Blocking now targets the **identity**, not just a single destination hash. All known destinations for the same identity are blocked, contacts are deleted, and LXMF stamp/ticket state is cleaned up from `LXMRouter`.
- **Banishment (UI)**: Blocked destinations page groups entries by identity and shows all blocked destination hashes per identity. Unblocking one unblocks the entire identity.
- **Banishment (Reticulum)**: `blackhole_identity()` is always applied when available to drop packets before LXMF delivery callbacks reach the sender, preventing "phantom deliveries" to blocked peers.
-- **NomadNet file downloads**: Backtick-separated request data (e.g. `/file/artifact`g=reticulum|r=lxmf|t=0.9.7`) is now parsed and forwarded as `var_*` request data dicts, matching upstream NomadNet behavior. Previously the raw string was passed and remote nodes could not resolve the artifact.
+- **NomadNet file downloads**: Backtick-separated request data (e.g. ``/file/artifact`g=reticulum|r=lxmf|t=0.9.7``) is now parsed and forwarded as `var_*` request data dicts, matching upstream NomadNet behavior. Previously the raw string was passed and remote nodes could not resolve the artifact.
- **NomadNet file downloads (cancel)**: Fixed `AttributeError` when cancelling a download — `RequestReceipt` has no `.cancel()`; we now cancel the underlying `Resource` if present, or mark the receipt `FAILED` and remove it from the link queue.
- **NomadNet browser (links)**: Relative `/page/` and `/file/` URLs from the Micron parser (which include backtick parameters) are now parsed correctly so they no longer show "Unsupported URL".
- **NomadNet browser (hover)**: Links with `data-destination` now show the full URL including backtick parameters in the browser hover title.
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index ab14006b..479e1f91 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -8,6 +8,7 @@ import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.hardware.usb.UsbManager;
@@ -45,6 +46,7 @@ import androidx.core.view.WindowCompat;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
import com.chaquo.python.Python;
import com.chaquo.python.android.AndroidPlatform;
import java.io.File;
@@ -65,6 +67,11 @@ import okhttp3.Response;
import org.json.JSONObject;
public class MainActivity extends AppCompatActivity {
+ private static final String SHELL_PREFS = "meshchatx_shell";
+ private static final String PREF_UI_THEME = "ui_theme";
+ private static final String THEME_DARK = "dark";
+ private static final String THEME_LIGHT = "light";
+
private WebView webView;
private ProgressBar progressBar;
private ImageView loadingLogo;
@@ -173,6 +180,10 @@ public class MainActivity extends AppCompatActivity {
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
+ boolean darkShell = isDarkUiTheme(resolvePreferredUiTheme());
+ getDelegate().setLocalNightMode(
+ darkShell ? AppCompatDelegate.MODE_NIGHT_YES : AppCompatDelegate.MODE_NIGHT_NO
+ );
getTheme().applyStyle(R.style.OptOutEdgeToEdgeEnforcement, false);
super.onCreate(savedInstanceState);
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
@@ -183,10 +194,9 @@ public class MainActivity extends AppCompatActivity {
loadingLogo = findViewById(R.id.loadingLogo);
loadingText = findViewById(R.id.loadingText);
errorText = findViewById(R.id.errorText);
- // Match MeshChatX canvas so WebView never flashes default white during Chaquopy boot.
- int canvasColor = getResources().getColor(R.color.meshchat_canvas, getTheme());
- getWindow().getDecorView().setBackgroundColor(canvasColor);
- webView.setBackgroundColor(canvasColor);
+ // Match MeshChatX canvas so WebView never flashes default white during Chaquopy boot
+ // or keyboard/resize gaps. Prefer last saved UI theme (default dark).
+ applyShellCanvasTheme(resolvePreferredUiTheme());
webView.setVisibility(android.view.View.INVISIBLE);
showLoading("Starting MeshChatX…");
@@ -812,6 +822,63 @@ public class MainActivity extends AppCompatActivity {
}, retryDelayMs);
}
+ private String resolvePreferredUiTheme() {
+ SharedPreferences prefs = getSharedPreferences(SHELL_PREFS, MODE_PRIVATE);
+ String stored = prefs.getString(PREF_UI_THEME, null);
+ if (THEME_LIGHT.equals(stored) || THEME_DARK.equals(stored)) {
+ return stored;
+ }
+ return THEME_DARK;
+ }
+
+ private static boolean isDarkUiTheme(String theme) {
+ return !THEME_LIGHT.equals(theme);
+ }
+
+ private void persistPreferredUiTheme(String theme) {
+ String normalized = isDarkUiTheme(theme) ? THEME_DARK : THEME_LIGHT;
+ getSharedPreferences(SHELL_PREFS, MODE_PRIVATE)
+ .edit()
+ .putString(PREF_UI_THEME, normalized)
+ .apply();
+ }
+
+ private void applyShellCanvasTheme(String theme) {
+ boolean dark = isDarkUiTheme(theme);
+ int canvasColor = ContextCompat.getColor(
+ this,
+ dark ? R.color.meshchat_canvas_dark : R.color.meshchat_canvas_light
+ );
+ getWindow().getDecorView().setBackgroundColor(canvasColor);
+ android.view.View content = findViewById(android.R.id.content);
+ if (content != null) {
+ content.setBackgroundColor(canvasColor);
+ }
+ if (webView != null) {
+ webView.setBackgroundColor(canvasColor);
+ }
+ if (loadingText != null) {
+ loadingText.setTextColor(
+ ContextCompat.getColor(this, dark ? R.color.white : R.color.black)
+ );
+ }
+ }
+
+ private void setUiThemeFromBridge(String theme) {
+ String normalized = isDarkUiTheme(theme) ? THEME_DARK : THEME_LIGHT;
+ persistPreferredUiTheme(normalized);
+ applyShellCanvasTheme(normalized);
+ // Update night mode without forcing an immediate recreate mid-session.
+ // Next cold start applies local night mode before setContentView.
+ int desired =
+ isDarkUiTheme(normalized)
+ ? AppCompatDelegate.MODE_NIGHT_YES
+ : AppCompatDelegate.MODE_NIGHT_NO;
+ if (getDelegate().getLocalNightMode() != desired) {
+ getDelegate().setLocalNightMode(desired);
+ }
+ }
+
private String toStackTrace(Throwable error) {
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
@@ -1125,6 +1192,16 @@ public class MainActivity extends AppCompatActivity {
return "android";
}
+ @JavascriptInterface
+ public String getPreferredUiTheme() {
+ return activity.resolvePreferredUiTheme();
+ }
+
+ @JavascriptInterface
+ public void setUiTheme(String theme) {
+ activity.runOnUiThread(() -> activity.setUiThemeFromBridge(theme));
+ }
+
@JavascriptInterface
public String getSidebandPluginsDefaultPath() {
try {
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 73b39ceb..2ad01ca2 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -7,7 +7,8 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
- <color name="meshchat_canvas">#FFF8FAFC</color>
+ <!-- Shell/WebView canvas. Default dark so native splash (white text) and dark UI do not flash light. -->
+ <color name="meshchat_canvas">#FF09090B</color>
<color name="meshchat_canvas_dark">#FF09090B</color>
+ <color name="meshchat_canvas_light">#FFF8FAFC</color>
</resources>
-
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
index 296c6494..39186615 100644
--- a/android/app/src/main/res/values/themes.xml
+++ b/android/app/src/main/res/values/themes.xml
@@ -8,8 +8,10 @@
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
+ <item name="android:navigationBarColor">@color/meshchat_canvas</item>
<item name="android:windowBackground">@color/meshchat_canvas</item>
<item name="android:colorBackground">@color/meshchat_canvas</item>
+ <!-- WebView prefers-color-scheme follows isLightTheme when targetSdk >= 33. -->
+ <item name="android:isLightTheme">false</item>
</style>
</resources>
-
diff --git a/docs/agents/skills/electron-frozen-packaging/SKILL.md b/docs/agents/skills/electron-frozen-packaging/SKILL.md
index 2ff163f7..c718a686 100644
--- a/docs/agents/skills/electron-frozen-packaging/SKILL.md
+++ b/docs/agents/skills/electron-frozen-packaging/SKILL.md
@@ -11,7 +11,7 @@ Package and recover the desktop shell correctly: frozen subprocess re-entry, loa
## Frozen executable rules
- In frozen builds, `sys.executable` **is** MeshChatX.
-- Never spawn `python -m …` for bots, rnsh, or LXMFy from the packaged app.
+- Never spawn `python -m …` or `python -c …` for bots, rnsh, LXMFy, or self-check probes from the packaged app.
- Use `--meshchatx-run-module <module>` so helpers re-enter the same binary without launching a second full app (storage lock collision).
## Loading and navigation
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 82a4e718..c3d653b6 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -206,7 +206,10 @@ from meshchatx.src.backend.recovery import (
format_memory_log_line,
)
from meshchatx.src.backend import reticulum_pathfinding
-from meshchatx.src.backend.rns_link_manager import RnsLinkManager
+from meshchatx.src.backend.rns_link_manager import (
+ RnsLinkManager,
+ clear_all_cached_links,
+)
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
from meshchatx.src.backend.sideband_commands import SidebandCommands
from meshchatx.src.backend.sticker_utils import (
@@ -1563,7 +1566,10 @@ class ReticulumMeshChat:
self.page_node_manager.start_all()
self.plugin_manager.set_app(self)
if self.plugins_enabled:
- self.plugin_manager.install_bundled_examples()
+ try:
+ self.plugin_manager.install_bundled_examples()
+ except Exception as exc:
+ print(f"Bundled plugin sync failed: {exc}", flush=True)
try:
self.sideband_plugin_loader.reload()
self._ensure_sideband_telemetry_loop()
@@ -1843,6 +1849,40 @@ class ReticulumMeshChat:
_meshchat_reload_epoch_min = 1_577_836_800
_meshchat_reload_epoch_max = 4_102_444_800
+ @staticmethod
+ def _reset_transport_globals_for_reload() -> None:
+ """Clear RNS Transport globals so a new Reticulum can start cleanly.
+
+ ``Reticulum.exit_handler`` sets ``Transport._should_run = False``. Upstream
+ ``Transport.start`` never flips it back, so hot reload must restore it or
+ the new jobloop exits immediately and path/link tools stay dead while
+ interface RX/TX counters still update.
+ """
+ RNS.Transport._should_run = True
+ if hasattr(RNS.Transport, "jobs_running"):
+ RNS.Transport.jobs_running = False
+ RNS.Transport.interfaces = []
+ RNS.Transport.local_client_interfaces = []
+ RNS.Transport.destinations = []
+ if hasattr(RNS.Transport, "destinations_map"):
+ RNS.Transport.destinations_map = {}
+ RNS.Transport.active_links = []
+ RNS.Transport.pending_links = []
+ RNS.Transport.announce_handlers = []
+ RNS.Transport.announce_table = {}
+ RNS.Transport.path_table = {}
+ RNS.Transport.reverse_table = {}
+ RNS.Transport.link_table = {}
+ RNS.Transport.held_announces = {}
+ RNS.Transport.tunnels = {}
+ RNS.Transport.path_requests = {}
+ RNS.Transport.path_states = {}
+ RNS.Transport.announce_rate_table = {}
+ RNS.Transport.control_destinations = []
+ RNS.Transport.control_hashes = []
+ RNS.Transport.mgmt_destinations = []
+ RNS.Transport.mgmt_hashes = []
+
@staticmethod
def _looks_like_meshchat_hot_reload_tail(pid: int, epoch: int) -> bool:
"""Limit repairs to suffixes :meth:`reload_reticulum` actually writes.
@@ -1952,6 +1992,7 @@ class ReticulumMeshChat:
# Signal background loops to exit
self._identity_session_id += 1
+ self._network_ready = False
await self._send_rns_reload_status(
"stopping-services",
@@ -2073,14 +2114,8 @@ class ReticulumMeshChat:
if hasattr(RNS.Reticulum, "_Reticulum__interface_detach_ran"):
RNS.Reticulum._Reticulum__interface_detach_ran = False
- # Also clear Transport caches and globals
- RNS.Transport.interfaces = []
- RNS.Transport.local_client_interfaces = []
- RNS.Transport.destinations = []
- RNS.Transport.active_links = []
- RNS.Transport.pending_links = []
- RNS.Transport.announce_handlers = []
- RNS.Transport.jobs_running = False
+ self._reset_transport_globals_for_reload()
+ clear_all_cached_links()
# Clear Identity globals
RNS.Identity.known_destinations = {}
@@ -2423,6 +2458,7 @@ class ReticulumMeshChat:
finally:
if switched_instance_name:
self._write_reticulum_instance_name(instance_restore_name)
+ self._mark_network_ready()
await self._send_rns_reload_status(
"done",
"RNS reload complete.",
@@ -4535,6 +4571,29 @@ class ReticulumMeshChat:
)
return None
+ def _require_rns_tool_handler(self, handler, tool_name: str):
+ """Return 503 when an RNS tool handler is unavailable (e.g. mid-reload)."""
+ if handler is None:
+ return web.json_response(
+ {
+ "message": f"{tool_name} is unavailable while the RNS stack is reloading.",
+ "stage": self._startup_stage,
+ "network_ready": bool(self._network_ready),
+ },
+ status=503,
+ )
+ reticulum = getattr(handler, "reticulum", None)
+ if reticulum is None and not hasattr(self, "reticulum"):
+ return web.json_response(
+ {
+ "message": f"{tool_name} is unavailable while the RNS stack is reloading.",
+ "stage": self._startup_stage,
+ "network_ready": bool(self._network_ready),
+ },
+ status=503,
+ )
+ return None
+
def _require_outbound_http(self, feature: str) -> None:
if self.config:
ensure_outbound_http_allowed(self.config, feature=feature)
@@ -13034,6 +13093,12 @@ class ReticulumMeshChat:
sorting = request.query.get("sorting")
sort_reverse = request.query.get("sort_reverse", "false") in ("true", "1")
+ not_ready = self._require_rns_tool_handler(
+ self.rnstatus_handler, "RNStatus"
+ )
+ if not_ready is not None:
+ return not_ready
+
try:
status = self.rnstatus_handler.get_status(
include_link_stats=include_link_stats,
@@ -13068,6 +13133,10 @@ class ReticulumMeshChat:
search = request.query.get("search")
interface = request.query.get("interface")
+ not_ready = self._require_rns_tool_handler(self.rnpath_handler, "RNPath")
+ if not_ready is not None:
+ return not_ready
+
try:
result = self.rnpath_handler.get_path_table(
max_hops=max_hops,
@@ -13083,6 +13152,9 @@ class ReticulumMeshChat:
@routes.get("/api/v1/rnpath/rates")
async def rnpath_rates(request):
+ not_ready = self._require_rns_tool_handler(self.rnpath_handler, "RNPath")
+ if not_ready is not None:
+ return not_ready
try:
rates = self.rnpath_handler.get_rate_table()
return web.json_response({"rates": rates})
@@ -13098,6 +13170,9 @@ class ReticulumMeshChat:
{"message": "destination_hash is required"},
status=400,
)
+ not_ready = self._require_rns_tool_handler(self.rnpath_handler, "RNPath")
+ if not_ready is not None:
+ return not_ready
try:
success = self.rnpath_handler.drop_path(destination_hash)
return web.json_response({"success": success})
@@ -13113,6 +13188,9 @@ class ReticulumMeshChat:
{"message": "transport_instance_hash is required"},
status=400,
)
+ not_ready = self._require_rns_tool_handler(self.rnpath_handler, "RNPath")
+ if not_ready is not None:
+ return not_ready
try:
success = self.rnpath_handler.drop_all_via(transport_instance_hash)
return web.json_response({"success": success})
@@ -13121,6 +13199,9 @@ class ReticulumMeshChat:
@routes.post("/api/v1/rnpath/drop-queues")
async def rnpath_drop_queues(request):
+ not_ready = self._require_rns_tool_handler(self.rnpath_handler, "RNPath")
+ if not_ready is not None:
+ return not_ready
try:
self.rnpath_handler.drop_announce_queues()
return web.json_response({"success": True})
@@ -13136,6 +13217,9 @@ class ReticulumMeshChat:
{"message": "destination_hash is required"},
status=400,
)
+ not_ready = self._require_rns_tool_handler(self.rnpath_handler, "RNPath")
+ if not_ready is not None:
+ return not_ready
try:
success = self.rnpath_handler.request_path(destination_hash)
return web.json_response({"success": success})
@@ -13206,6 +13290,10 @@ class ReticulumMeshChat:
status=400,
)
+ not_ready = self._require_rns_tool_handler(self.rnprobe_handler, "RNProbe")
+ if not_ready is not None:
+ return not_ready
+
try:
result = await self.rnprobe_handler.probe_destination(
destination_hash=destination_hash,
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 66f3464e..1a8bae56 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -701,6 +701,11 @@ class IdentityContext:
self.rncp_handler.teardown_receive_destination()
self.rncp_handler = None
+ self.rnstatus_handler = None
+ self.rnpath_handler = None
+ self.rnpath_trace_handler = None
+ self.rnprobe_handler = None
+
if self.message_router:
# Break cycles in mocks/objects
if hasattr(self.message_router, "register_delivery_callback"):
diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py
index 80eb8b89..8a93daad 100644
--- a/meshchatx/src/backend/markdown_renderer.py
+++ b/meshchatx/src/backend/markdown_renderer.py
@@ -77,6 +77,24 @@ class MarkdownRenderer:
flags=re.DOTALL,
)
+ # Inline code before emphasis so snake_case / ``rst`` spans are not
+ # mangled by underscore italic (changelog uses both `code` and ``code``).
+ inline_codes: list[str] = []
+
+ def inline_code_placeholder(match):
+ code = match.group(1)
+ placeholder = f"[[IC{len(inline_codes)}]]"
+ inline_codes.append(
+ f'<code class="bg-gray-100 dark:bg-zinc-800 px-1.5 py-0.5 '
+ f"rounded-sm text-pink-600 dark:text-pink-400 font-mono "
+ f'text-[0.9em]">{code}</code>',
+ )
+ return placeholder
+
+ # Double-backtick spans first (CommonMark / changelog RST-style).
+ text = re.sub(r"``([^`]+)``", inline_code_placeholder, text)
+ text = re.sub(r"`([^`]+)`", inline_code_placeholder, text)
+
text = MarkdownRenderer._render_tables(text)
# Horizontal Rules
@@ -135,24 +153,20 @@ class MarkdownRenderer:
flags=re.MULTILINE,
)
- # Bold and Italic
+ # Bold and Italic (underscore italic requires word boundaries so
+ # identifiers like local_hops_delta and api_extensions stay intact).
text = re.sub(r"\*\*\*(.+?)\*\*\*", r"<strong><em>\1</em></strong>", text)
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text = re.sub(r"\*(?!\s)(.+?)(?<!\s)\*", r"<em>\1</em>", text)
text = re.sub(r"___(.+?)___", r"<strong><em>\1</em></strong>", text)
- text = re.sub(r"__(.+?)__", r"<strong>\1</strong>", text)
- text = re.sub(r"_(?!\s)(.+?)(?<!\s)_", r"<em>\1</em>", text)
+ text = re.sub(
+ r"(?<!\w)__(?!\s)(.+?)(?<!\s)__(?!\w)", r"<strong>\1</strong>", text
+ )
+ text = re.sub(r"(?<!\w)_(?!\s)(.+?)(?<!\s)_(?!\w)", r"<em>\1</em>", text)
# Strikethrough
text = re.sub(r"~~(.*?)~~", r"<del>\1</del>", text)
- # Inline code
- text = re.sub(
- r"`([^`]+)`",
- r'<code class="bg-gray-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded-sm text-pink-600 dark:text-pink-400 font-mono text-[0.9em]">\1</code>',
- text,
- )
-
# Task lists
text = re.sub(
r"^[-*] \[ \] (.*)$",
@@ -245,7 +259,7 @@ class MarkdownRenderer:
continue
# If it's a placeholder for code block, don't wrap in <p>
- if part.startswith("[[CB") and part.endswith("]]"):
+ if re.fullmatch(r"\[\[(?:CB|IC)\d+\]\]", part):
processed_parts.append(part)
continue
@@ -261,7 +275,9 @@ class MarkdownRenderer:
text = "\n".join(processed_parts)
- # Restore code blocks
+ # Restore inline code then fenced blocks (fenced last so IC inside CB is fine).
+ for i, code_html in enumerate(inline_codes):
+ text = text.replace(f"[[IC{i}]]", code_html)
for i, code_html in enumerate(code_blocks):
text = text.replace(f"[[CB{i}]]", code_html)
diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index 511370ae..6aae3d5a 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -515,8 +515,9 @@ class PluginManager:
granted = normalize_granted_permissions(declared, granted_permissions)
target_dir = os.path.join(self.installed_dir, plugin_id)
if os.path.exists(target_dir):
- shutil.rmtree(target_dir)
+ self._remove_install_tree(target_dir)
shutil.copytree(source_dir, target_dir)
+ self._normalize_install_tree_permissions(target_dir)
integrity_hash = compute_dir_integrity_hash(target_dir)
with self._lock:
(
@@ -1500,6 +1501,65 @@ class PluginManager:
)
AsyncUtils.run_async(self.app.websocket_broadcast(message))
+ @staticmethod
+ def _chmod_path(path: str, mode: int) -> None:
+ try:
+ os.chmod(path, mode)
+ except OSError:
+ pass
+
+ def _normalize_install_tree_permissions(self, root: str) -> None:
+ """Make installed trees deletable on Android/AssetFinder upgrades."""
+ for dirpath, dirnames, filenames in os.walk(root):
+ self._chmod_path(dirpath, 0o755)
+ for name in dirnames:
+ self._chmod_path(os.path.join(dirpath, name), 0o755)
+ for name in filenames:
+ self._chmod_path(os.path.join(dirpath, name), 0o644)
+
+ def _remove_install_tree(self, target_dir: str) -> None:
+ """Remove an install tree, fixing read-only modes copied from APK assets."""
+
+ def _onerror(func, path, _exc_info):
+ self._chmod_path(path, 0o700 if os.path.isdir(path) else 0o600)
+ parent = os.path.dirname(path)
+ if parent:
+ self._chmod_path(parent, 0o700)
+ func(path)
+
+ if not os.path.exists(target_dir):
+ return
+ self._normalize_install_tree_permissions(target_dir)
+ shutil.rmtree(target_dir, onerror=_onerror)
+
+ def _bundled_needs_reinstall(
+ self, source_dir: str, manifest: dict[str, Any]
+ ) -> bool:
+ plugin_id = manifest.get("id")
+ if not isinstance(plugin_id, str) or not plugin_id:
+ return False
+ existing = self._plugins.get(plugin_id)
+ if existing is None:
+ return True
+ if existing.version != manifest.get("version"):
+ return True
+ target_dir = os.path.join(self.installed_dir, plugin_id)
+ if not os.path.isdir(target_dir):
+ return True
+ try:
+ bundled_hash = compute_dir_integrity_hash(source_dir)
+ except Exception:
+ return True
+ if existing.integrity_hash and existing.integrity_hash != bundled_hash:
+ return True
+ if not existing.integrity_hash:
+ try:
+ installed_hash = compute_dir_integrity_hash(target_dir)
+ except Exception:
+ return True
+ return installed_hash != bundled_hash
+ return False
+
def install_bundled_examples(self) -> None:
if not self._plugins_runtime_enabled():
return
@@ -1519,4 +1579,22 @@ class PluginManager:
manifest_path = os.path.join(source, "plugin.json")
if not os.path.isfile(manifest_path):
continue
- self.install_from_directory(source)
+ try:
+ with open(manifest_path, encoding="utf-8") as handle:
+ manifest = json.load(handle)
+ except Exception as exc:
+ print(
+ f"Bundled plugin manifest read failed for {name}: {exc}", flush=True
+ )
+ continue
+ if not isinstance(manifest, dict):
+ continue
+ if not self._bundled_needs_reinstall(source, manifest):
+ continue
+ try:
+ self.install_from_directory(source)
+ except Exception as exc:
+ print(
+ f"Bundled plugin sync failed for {manifest.get('id', name)}: {exc}",
+ flush=True,
+ )
diff --git a/meshchatx/src/backend/rns_link_manager.py b/meshchatx/src/backend/rns_link_manager.py
index 25d70e48..8325396b 100644
--- a/meshchatx/src/backend/rns_link_manager.py
+++ b/meshchatx/src/backend/rns_link_manager.py
@@ -127,6 +127,21 @@ def sweep_stale_links():
_teardown_links(to_teardown)
+def clear_all_cached_links() -> int:
+ """Tear down every cached RNS link (used after RNS hot reload).
+
+ ``sweep_stale_links`` leaves ACTIVE links alone. After Transport reset those
+ objects are tied to the old stack and must be dropped.
+ """
+ with _rns_links_lock:
+ to_teardown = list(rns_cached_links.values())
+ rns_cached_links.clear()
+ _rns_link_last_used.clear()
+ _link_failure_counts.clear()
+ _teardown_links(to_teardown)
+ return len(to_teardown)
+
+
def _cache_link_if_active(aspect: str, destination_hash: bytes, link) -> None:
if link is None or link.status is not RNS.Link.ACTIVE:
return
diff --git a/meshchatx/src/backend/self_check.py b/meshchatx/src/backend/self_check.py
index e63536ca..35df5dac 100644
--- a/meshchatx/src/backend/self_check.py
+++ b/meshchatx/src/backend/self_check.py
@@ -374,22 +374,45 @@ def check_meshchatx_run_module() -> dict[str, str]:
def check_subprocess_spawn() -> dict[str, str]:
- """Spawn a short-lived child process (covers Windows CreateProcess / POSIX fork)."""
+ """Spawn a short-lived child process (covers Windows CreateProcess / POSIX fork).
+
+ Frozen desktop builds (AppImage / EXE / macOS) set ``sys.executable`` to
+ MeshChatX itself, which rejects Python ``-c``. Those builds re-enter via
+ ``--meshchatx-run-module`` like bots and rnsh.
+ """
try:
+ env = {**os.environ, "PYTHONUNBUFFERED": "1"}
+ if _is_frozen_executable():
+ env["MESHCHAT_SKIP_STORAGE_LOCK"] = "1"
+ cmd = [
+ sys.executable,
+ _MESHCHATX_RUN_MODULE_FLAG,
+ _SELF_CHECK_PROBE_MODULE,
+ "spawn-ok",
+ ]
+ expected = "meshchatx-self-check-probe"
+ else:
+ cmd = [
+ sys.executable,
+ "-c",
+ "print('meshchatx-spawn-ok', flush=True)",
+ ]
+ expected = "meshchatx-spawn-ok"
+
result = subprocess.run(
- [sys.executable, "-c", "print('meshchatx-spawn-ok', flush=True)"],
+ cmd,
capture_output=True,
text=True,
timeout=30,
check=False,
- env={**os.environ, "PYTHONUNBUFFERED": "1"},
+ env=env,
)
if result.returncode != 0:
return _status(
False,
f"spawn exited {result.returncode}: {(result.stderr or result.stdout or '')[-300:]}",
)
- if "meshchatx-spawn-ok" not in (result.stdout or ""):
+ if expected not in (result.stdout or ""):
return _status(False, f"Unexpected spawn output: {result.stdout!r}")
return _status(True)
except Exception as exc:
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 72191005..0db59b1c 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1268,6 +1268,21 @@ export default {
const mode = theme === "dark" ? "dark" : "light";
if (typeof document !== "undefined") {
document.documentElement.classList.toggle("dark", mode === "dark");
+ document.documentElement.dataset.bootTheme = mode;
+ document.documentElement.style.colorScheme = mode;
+ }
+ try {
+ window.localStorage.setItem("meshchatx_ui_theme", mode);
+ } catch {
+ // ignore quota / private mode
+ }
+ try {
+ const bridge = window.MeshChatXAndroid;
+ if (bridge && typeof bridge.setUiTheme === "function") {
+ bridge.setUiTheme(mode);
+ }
+ } catch {
+ // ignore missing bridge
}
if (typeof this.vuetifyTheme?.change === "function") {
this.vuetifyTheme.change(mode);
diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 5912d460..bd2dd25c 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -389,6 +389,34 @@
</div>
<div class="grid grid-cols-1 gap-4">
+ <button
+ type="button"
+ class="text-left flex items-start gap-4 p-5 rounded-2xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all"
+ :class="[
+ connectionMode === 'discovery'
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
+ : 'border-blue-500/20 hover:border-blue-500',
+ ]"
+ :disabled="savingDiscovery"
+ @click="useDiscoveryMode"
+ >
+ <v-icon icon="mdi-radar" color="blue" size="40"></v-icon>
+ <div class="flex-1 min-w-0">
+ <div class="font-bold text-lg text-gray-900 dark:text-white">
+ {{ $t("tutorial.mode_discovery_title") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-zinc-400 mt-1">
+ {{ $t("tutorial.mode_discovery_desc") }}
+ </div>
+ </div>
+ <v-progress-circular
+ v-if="savingDiscovery"
+ indeterminate
+ size="20"
+ width="2"
+ ></v-progress-circular>
+ </button>
+
<button
type="button"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all"
@@ -444,8 +472,308 @@
</p>
</div>
- <!-- Step 4: Propagation Mode -->
- <div v-else-if="currentStep === 4" key="step4-prop" class="space-y-6">
+ <!-- Step 4: Bootstrap Selection -->
+ <div v-else-if="currentStep === 4" key="step4-bootstrap" class="space-y-6">
+ <div class="text-center space-y-2">
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.bootstrap_title") }}
+ </h2>
+ <p class="text-gray-600 dark:text-zinc-400 text-sm">
+ {{ $t("tutorial.bootstrap_desc") }}
+ </p>
+ <div class="flex flex-col items-center gap-2 pt-1">
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-2 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
+ :disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
+ @click="pickRandomTcpBootstraps"
+ >
+ <v-progress-circular
+ v-if="pickingRandomBootstraps"
+ indeterminate
+ size="16"
+ width="2"
+ />
+ <v-icon v-else icon="mdi-shuffle-variant" size="18" />
+ {{ $t("tutorial.bootstrap_pick_random_tcp") }}
+ </button>
+ <div
+ v-if="bootstrapSelectedLabels.length > 0"
+ class="w-full max-w-md rounded-xl border border-gray-200/90 bg-gray-50/80 px-3 py-2 text-left dark:border-zinc-700 dark:bg-zinc-900/50"
+ >
+ <div
+ class="text-[10px] font-bold uppercase tracking-wide text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("tutorial.bootstrap_selected_nodes_heading") }}
+ </div>
+ <ul class="mt-1 space-y-0.5 text-xs text-gray-800 dark:text-zinc-200">
+ <li
+ v-for="(label, idx) in bootstrapSelectedLabels"
+ :key="selectedBootstrapKeys[idx]"
+ >
+ {{ label }}
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex items-start gap-3 sm:gap-4 rounded-2xl border border-gray-200 dark:border-zinc-700 bg-white/80 dark:bg-zinc-900/60 p-3.5 sm:p-4"
+ >
+ <div class="shrink-0 pr-0.5 pt-0.5 sm:pt-1 sm:pr-1 flex items-start">
+ <Toggle
+ v-model="defaultBootstrapOnly"
+ @update:model-value="persistDefaultBootstrapOnly"
+ />
+ </div>
+ <div class="min-w-0 flex-1 pl-0.5 sm:pl-0 sm:pt-0.5">
+ <div class="text-sm font-semibold text-gray-900 dark:text-white leading-snug">
+ {{ $t("tutorial.bootstrap_only_label") }}
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-400 mt-1.5 leading-relaxed">
+ {{ $t("tutorial.bootstrap_only_hint") }}
+ </p>
+ </div>
+ </div>
+
+ <div class="space-y-4">
+ <div
+ v-if="hasAnyBootstrapsToShow"
+ class="w-full max-w-6xl mx-auto flex items-center gap-2 border-0 border-b border-gray-200/90 dark:border-zinc-600/90 py-1.5"
+ >
+ <v-icon icon="mdi-magnify" size="20" class="shrink-0 text-gray-400" />
+ <input
+ v-model="bootstrapListSearch"
+ type="search"
+ autocomplete="off"
+ :placeholder="$t('tutorial.bootstrap_search_placeholder')"
+ class="min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-gray-900 shadow-none ring-0 outline-hidden focus:ring-0 dark:text-zinc-100 placeholder:text-gray-400 dark:placeholder:text-zinc-500"
+ />
+ <button
+ v-if="bootstrapListSearch"
+ type="button"
+ class="shrink-0 rounded p-1 text-gray-400 transition-colors hover:text-gray-700 dark:hover:text-zinc-200"
+ :title="$t('tutorial.bootstrap_search_clear')"
+ :aria-label="$t('tutorial.bootstrap_search_clear')"
+ @click="bootstrapListSearch = ''"
+ >
+ <v-icon icon="mdi-close" size="18" />
+ </button>
+ </div>
+
+ <div
+ v-if="sortedDiscoveredInterfaces.length > 0"
+ class="h-fit min-w-0 bg-emerald-500/5 dark:bg-emerald-500/10 rounded-3xl border border-emerald-500/20"
+ >
+ <button
+ type="button"
+ class="flex w-full items-center justify-between gap-2 p-4 text-left sm:px-4"
+ :aria-expanded="bootstrapDiscoveredSectionOpen"
+ @click="bootstrapDiscoveredSectionOpen = !bootstrapDiscoveredSectionOpen"
+ >
+ <div class="flex min-w-0 items-center gap-2 text-sm">
+ <MaterialDesignIcon
+ :icon-name="bootstrapDiscoveredSectionOpen ? 'chevron-up' : 'chevron-down'"
+ class="size-4 shrink-0 text-gray-500"
+ />
+ <v-icon icon="mdi-radar" color="emerald"></v-icon>
+ <span class="font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.bootstrap_discovered")
+ }}</span>
+ </div>
+ </button>
+ <div v-show="bootstrapDiscoveredSectionOpen" class="px-4 pb-4">
+ <p
+ v-if="
+ bootstrapListSearch &&
+ sortedDiscoveredInterfaces.length > 0 &&
+ filteredDiscoveredForBootstrap.length === 0
+ "
+ class="text-xs text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("tutorial.bootstrap_search_no_match") }}
+ </p>
+ <div
+ v-else
+ class="space-y-2 max-h-[260px] overflow-y-auto pr-2 pt-1 custom-scrollbar"
+ >
+ <label
+ v-for="iface in filteredDiscoveredForBootstrap"
+ :key="iface.discovery_hash || iface.name"
+ class="flex cursor-pointer items-center gap-3 rounded-xl border bg-white p-3 transition-all dark:bg-zinc-800"
+ :class="[
+ isBootstrapSelected(`disc:${iface.discovery_hash || iface.name}`)
+ ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
+ : 'border-gray-100 dark:border-zinc-700 hover:border-emerald-400',
+ ]"
+ >
+ <input
+ type="checkbox"
+ class="h-4 w-4 accent-emerald-500"
+ :checked="
+ isBootstrapSelected(`disc:${iface.discovery_hash || iface.name}`)
+ "
+ @change="toggleBootstrap(`disc:${iface.discovery_hash || iface.name}`)"
+ />
+ <MaterialDesignIcon
+ :icon-name="getDiscoveryIcon(iface)"
+ class="h-5 w-5 shrink-0 text-emerald-500"
+ />
+ <div class="min-w-0 flex-1">
+ <div class="truncate text-sm font-bold text-gray-900 dark:text-white">
+ {{ iface.name }}
+ </div>
+ <div
+ class="truncate font-mono text-[10px] text-gray-500 dark:text-zinc-400"
+ >
+ <span v-if="iface.reachable_on"
+ >{{ iface.reachable_on
+ }}<span v-if="iface.port">:{{ iface.port }}</span></span
+ >
+ <span v-else>{{ iface.type }}</span>
+ <span class="ml-2 capitalize">{{ iface.status }}</span>
+ </div>
+ </div>
+ </label>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="h-fit min-w-0 rounded-3xl border border-gray-100 bg-gray-50 p-0 dark:border-zinc-800 dark:bg-zinc-900"
+ >
+ <div class="flex items-center justify-between gap-2 p-4 pr-2 sm:px-4">
+ <button
+ type="button"
+ class="flex min-w-0 flex-1 items-center gap-2 text-left text-sm"
+ :aria-expanded="bootstrapCommunitySectionOpen"
+ @click="bootstrapCommunitySectionOpen = !bootstrapCommunitySectionOpen"
+ >
+ <MaterialDesignIcon
+ :icon-name="bootstrapCommunitySectionOpen ? 'chevron-up' : 'chevron-down'"
+ class="size-4 shrink-0 text-gray-500"
+ />
+ <v-icon icon="mdi-web" color="blue"></v-icon>
+ <span class="font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.bootstrap_community")
+ }}</span>
+ </button>
+ <button
+ type="button"
+ class="shrink-0 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600 disabled:opacity-50 dark:hover:bg-zinc-800 dark:hover:text-blue-400"
+ :disabled="refreshingCommunityPresets"
+ :title="$t('interfaces.community_presets_refresh')"
+ :aria-label="$t('interfaces.community_presets_refresh')"
+ @click.stop="refreshCommunityPresets"
+ >
+ <v-icon
+ icon="mdi-refresh"
+ size="20"
+ :class="{ 'animate-spin': refreshingCommunityPresets }"
+ />
+ </button>
+ </div>
+ <div v-show="bootstrapCommunitySectionOpen" class="px-4 pb-4">
+ <p
+ v-if="
+ bootstrapListSearch &&
+ communityInterfaces.length > 0 &&
+ filteredCommunityForBootstrap.length === 0
+ "
+ class="text-xs text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("tutorial.bootstrap_search_no_match") }}
+ </p>
+ <div
+ v-else
+ class="space-y-2 max-h-[260px] overflow-y-auto pr-2 pt-1 custom-scrollbar"
+ >
+ <label
+ v-for="iface in filteredCommunityForBootstrap"
+ :key="iface.name"
+ class="flex cursor-pointer items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 transition-all dark:border-zinc-700 dark:bg-zinc-800"
+ :class="[
+ isBootstrapSelected(`comm:${iface.name}`)
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'hover:border-blue-400',
+ ]"
+ >
+ <input
+ type="checkbox"
+ class="h-4 w-4 accent-blue-500"
+ :checked="isBootstrapSelected(`comm:${iface.name}`)"
+ @change="toggleBootstrap(`comm:${iface.name}`)"
+ />
+ <v-icon icon="mdi-server-network" color="blue" size="20"></v-icon>
+ <div class="min-w-0 flex-1">
+ <div class="truncate text-sm font-bold text-gray-900 dark:text-white">
+ {{ iface.name }}
+ </div>
+ <div
+ class="truncate font-mono text-[10px] text-gray-500 dark:text-zinc-400"
+ >
+ {{ iface.target_host
+ }}<span v-if="iface.target_port">:{{ iface.target_port }}</span>
+ </div>
+ </div>
+ <span
+ v-if="iface.online"
+ class="shrink-0 text-[9px] font-bold uppercase tracking-widest text-green-500"
+ >{{ $t("tutorial.online") }}</span
+ >
+ </label>
+ <div v-if="loadingInterfaces" class="flex justify-center py-3">
+ <v-progress-circular
+ indeterminate
+ color="blue"
+ size="24"
+ ></v-progress-circular>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex flex-col sm:flex-row items-center justify-between gap-3 pt-2">
+ <p class="text-xs text-gray-500 dark:text-zinc-500">
+ {{
+ $t("tutorial.bootstrap_selected", {
+ count: selectedBootstrapCount,
+ })
+ }}
+ </p>
+ <div class="flex gap-2">
+ <button
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-secondary"
+ @click="skipBootstraps"
+ >
+ {{ $t("tutorial.bootstrap_skip") }}
+ </button>
+ <button
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-success"
+ :disabled="
+ addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0
+ "
+ @click="confirmBootstraps"
+ >
+ <v-progress-circular
+ v-if="addingBootstraps || reloadingReticulum"
+ indeterminate
+ size="14"
+ width="2"
+ class="mr-1"
+ ></v-progress-circular>
+ {{ $t("tutorial.bootstrap_confirm") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Step 5: Propagation Mode -->
+ <div v-else-if="currentStep === 5" key="step5-prop" class="space-y-6">
<div class="text-center space-y-2">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.propagation") }}
@@ -502,8 +830,8 @@
</div>
</div>
- <!-- Step 5: Learn & Create -->
- <div v-else-if="currentStep === 5" key="step5-tools" class="space-y-6">
+ <!-- Step 6: Learn & Create -->
+ <div v-else-if="currentStep === 6" key="step6-tools" class="space-y-6">
<div class="text-center space-y-2">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.learn_create") }}
@@ -712,10 +1040,10 @@
</div>
</div>
- <!-- Step 6: Finish -->
+ <!-- Step 7: Finish -->
<div
- v-else-if="currentStep === 6"
- key="step6-finish"
+ v-else-if="currentStep === 7"
+ key="step7-finish"
class="flex flex-col items-center text-center space-y-8 py-10"
>
<div class="w-32 h-32 bg-green-500/10 rounded-full flex items-center justify-center relative">
@@ -1175,7 +1503,33 @@
</p>
</div>
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-6xl mx-auto">
+ <button
+ type="button"
+ class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all hover:scale-[1.02]"
+ :class="[
+ connectionMode === 'discovery'
+ ? 'border-blue-500 ring-2 ring-blue-500/30'
+ : 'border-blue-500/20 hover:border-blue-500',
+ ]"
+ :disabled="savingDiscovery"
+ @click="useDiscoveryMode"
+ >
+ <v-icon icon="mdi-radar" color="blue" size="56"></v-icon>
+ <div class="font-bold text-xl text-gray-900 dark:text-white">
+ {{ $t("tutorial.mode_discovery_title") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-zinc-400">
+ {{ $t("tutorial.mode_discovery_desc") }}
+ </div>
+ <v-progress-circular
+ v-if="savingDiscovery"
+ indeterminate
+ size="20"
+ width="2"
+ ></v-progress-circular>
+ </button>
+
<button
type="button"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all hover:scale-[1.02]"
@@ -1227,8 +1581,313 @@
</p>
</div>
- <!-- Step 4: Propagation Mode -->
- <div v-else-if="currentStep === 4" key="page-step4-prop" class="space-y-8 py-12">
+ <!-- Step 4: Bootstrap Selection -->
+ <div v-else-if="currentStep === 4" key="page-step4-bootstrap" class="space-y-6 py-8">
+ <div class="text-center space-y-2">
+ <h2 class="text-3xl font-black text-gray-900 dark:text-white">
+ {{ $t("tutorial.bootstrap_title") }}
+ </h2>
+ <p class="text-lg text-gray-600 dark:text-zinc-400 max-w-3xl mx-auto">
+ {{ $t("tutorial.bootstrap_desc_page") }}
+ </p>
+ <div class="flex flex-col items-center gap-3 pt-2">
+ <button
+ type="button"
+ class="inline-flex items-center gap-2 rounded-xl border-2 border-blue-500/30 bg-blue-500/10 px-5 py-2.5 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
+ :disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
+ @click="pickRandomTcpBootstraps"
+ >
+ <v-progress-circular
+ v-if="pickingRandomBootstraps"
+ indeterminate
+ size="18"
+ width="2"
+ />
+ <v-icon v-else icon="mdi-shuffle-variant" size="20" />
+ {{ $t("tutorial.bootstrap_pick_random_tcp") }}
+ </button>
+ <div
+ v-if="bootstrapSelectedLabels.length > 0"
+ class="w-full max-w-xl rounded-xl border border-gray-200/90 bg-gray-50/80 px-4 py-3 text-left dark:border-zinc-700 dark:bg-zinc-900/50"
+ >
+ <div
+ class="text-xs font-bold uppercase tracking-wide text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("tutorial.bootstrap_selected_nodes_heading") }}
+ </div>
+ <ul class="mt-1.5 space-y-1 text-sm text-gray-800 dark:text-zinc-200">
+ <li
+ v-for="(label, idx) in bootstrapSelectedLabels"
+ :key="selectedBootstrapKeys[idx]"
+ >
+ {{ label }}
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex items-start gap-3 sm:gap-5 max-w-3xl mx-auto rounded-2xl border border-gray-200 dark:border-zinc-700 bg-white/80 dark:bg-zinc-900/60 p-3.5 sm:p-5"
+ >
+ <div class="shrink-0 pr-0.5 pt-0.5 sm:pt-1.5 sm:pr-1 flex items-start">
+ <Toggle
+ v-model="defaultBootstrapOnly"
+ @update:model-value="persistDefaultBootstrapOnly"
+ />
+ </div>
+ <div class="min-w-0 flex-1 pl-0.5 sm:pl-0 sm:pt-0.5">
+ <div
+ class="text-sm sm:text-base font-semibold text-gray-900 dark:text-white leading-snug"
+ >
+ {{ $t("tutorial.bootstrap_only_label") }}
+ </div>
+ <p
+ class="text-xs sm:text-sm text-gray-500 dark:text-zinc-400 mt-1.5 sm:mt-2 leading-relaxed"
+ >
+ {{ $t("tutorial.bootstrap_only_hint") }}
+ </p>
+ </div>
+ </div>
+
+ <div
+ v-if="hasAnyBootstrapsToShow"
+ class="flex w-full max-w-6xl mx-auto items-center gap-2 border-0 border-b border-gray-200/90 dark:border-zinc-600/90 py-1.5"
+ >
+ <v-icon icon="mdi-magnify" size="22" class="shrink-0 text-gray-400" />
+ <input
+ v-model="bootstrapListSearch"
+ type="search"
+ autocomplete="off"
+ :placeholder="$t('tutorial.bootstrap_search_placeholder')"
+ class="min-w-0 flex-1 border-0 bg-transparent p-0 text-base text-gray-900 shadow-none ring-0 outline-hidden focus:ring-0 dark:text-zinc-100 placeholder:text-gray-400 dark:placeholder:text-zinc-500"
+ />
+ <button
+ v-if="bootstrapListSearch"
+ type="button"
+ class="shrink-0 rounded p-1.5 text-gray-400 transition-colors hover:text-gray-700 dark:hover:text-zinc-200"
+ :title="$t('tutorial.bootstrap_search_clear')"
+ :aria-label="$t('tutorial.bootstrap_search_clear')"
+ @click="bootstrapListSearch = ''"
+ >
+ <v-icon icon="mdi-close" size="20" />
+ </button>
+ </div>
+
+ <div class="grid max-w-6xl mx-auto grid-cols-1 items-start gap-6 lg:grid-cols-2">
+ <div
+ v-if="sortedDiscoveredInterfaces.length > 0"
+ class="h-fit min-w-0 bg-emerald-500/5 dark:bg-emerald-500/10 rounded-3xl border border-emerald-500/20"
+ >
+ <button
+ type="button"
+ class="flex w-full items-center justify-between gap-2 p-4 text-left sm:px-5"
+ :aria-expanded="bootstrapDiscoveredSectionOpen"
+ @click="bootstrapDiscoveredSectionOpen = !bootstrapDiscoveredSectionOpen"
+ >
+ <div class="flex min-w-0 items-center gap-2.5 text-base">
+ <MaterialDesignIcon
+ :icon-name="bootstrapDiscoveredSectionOpen ? 'chevron-up' : 'chevron-down'"
+ class="size-4 shrink-0 text-gray-500"
+ />
+ <v-icon icon="mdi-radar" color="emerald" size="22"></v-icon>
+ <span class="font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.bootstrap_discovered")
+ }}</span>
+ </div>
+ </button>
+ <div v-show="bootstrapDiscoveredSectionOpen" class="px-4 pb-5 sm:px-5">
+ <p
+ v-if="
+ bootstrapListSearch &&
+ sortedDiscoveredInterfaces.length > 0 &&
+ filteredDiscoveredForBootstrap.length === 0
+ "
+ class="text-sm text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("tutorial.bootstrap_search_no_match") }}
+ </p>
+ <div
+ v-else
+ class="max-h-[480px] space-y-2 overflow-y-auto pr-2 pt-1 custom-scrollbar"
+ >
+ <label
+ v-for="iface in filteredDiscoveredForBootstrap"
+ :key="iface.discovery_hash || iface.name"
+ class="flex cursor-pointer items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 transition-all dark:border-zinc-700 dark:bg-zinc-800"
+ :class="[
+ isBootstrapSelected(`disc:${iface.discovery_hash || iface.name}`)
+ ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
+ : 'hover:border-emerald-400',
+ ]"
+ >
+ <input
+ type="checkbox"
+ class="h-4 w-4 accent-emerald-500"
+ :checked="
+ isBootstrapSelected(`disc:${iface.discovery_hash || iface.name}`)
+ "
+ @change="toggleBootstrap(`disc:${iface.discovery_hash || iface.name}`)"
+ />
+ <MaterialDesignIcon
+ :icon-name="getDiscoveryIcon(iface)"
+ class="h-5 w-5 shrink-0 text-emerald-500"
+ />
+ <div class="min-w-0 flex-1">
+ <div class="truncate text-sm font-bold text-gray-900 dark:text-white">
+ {{ iface.name }}
+ </div>
+ <div
+ class="truncate font-mono text-[10px] text-gray-500 dark:text-zinc-400"
+ >
+ <span v-if="iface.reachable_on"
+ >{{ iface.reachable_on
+ }}<span v-if="iface.port">:{{ iface.port }}</span></span
+ >
+ <span v-else>{{ iface.type }}</span>
+ <span class="ml-2 capitalize">{{ iface.status }}</span>
+ </div>
+ </div>
+ </label>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="h-fit min-w-0 rounded-3xl border border-gray-100 bg-gray-50 p-0 dark:border-zinc-800 dark:bg-zinc-900"
+ :class="[sortedDiscoveredInterfaces.length === 0 ? 'lg:col-span-2' : '']"
+ >
+ <div class="flex items-center justify-between gap-2 p-4 pr-2 sm:px-5">
+ <button
+ type="button"
+ class="flex min-w-0 flex-1 items-center gap-2.5 text-left text-base"
+ :aria-expanded="bootstrapCommunitySectionOpen"
+ @click="bootstrapCommunitySectionOpen = !bootstrapCommunitySectionOpen"
+ >
+ <MaterialDesignIcon
+ :icon-name="bootstrapCommunitySectionOpen ? 'chevron-up' : 'chevron-down'"
+ class="size-4 shrink-0 text-gray-500"
+ />
+ <v-icon icon="mdi-web" color="blue" size="22"></v-icon>
+ <span class="font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.bootstrap_community")
+ }}</span>
+ </button>
+ <button
+ type="button"
+ class="shrink-0 rounded-lg p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600 disabled:opacity-50 dark:hover:bg-zinc-800 dark:hover:text-blue-400"
+ :disabled="refreshingCommunityPresets"
+ :title="$t('interfaces.community_presets_refresh')"
+ :aria-label="$t('interfaces.community_presets_refresh')"
+ @click.stop="refreshCommunityPresets"
+ >
+ <v-icon
+ icon="mdi-refresh"
+ size="22"
+ :class="{ 'animate-spin': refreshingCommunityPresets }"
+ />
+ </button>
+ </div>
+ <div v-show="bootstrapCommunitySectionOpen" class="px-4 pb-5 sm:px-5">
+ <p
+ v-if="
+ bootstrapListSearch &&
+ communityInterfaces.length > 0 &&
+ filteredCommunityForBootstrap.length === 0
+ "
+ class="text-sm text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("tutorial.bootstrap_search_no_match") }}
+ </p>
+ <div
+ v-else
+ class="max-h-[480px] space-y-2 overflow-y-auto pr-2 pt-1 custom-scrollbar"
+ >
+ <label
+ v-for="iface in filteredCommunityForBootstrap"
+ :key="iface.name"
+ class="flex cursor-pointer items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 transition-all dark:border-zinc-700 dark:bg-zinc-800"
+ :class="[
+ isBootstrapSelected(`comm:${iface.name}`)
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
+ : 'hover:border-blue-400',
+ ]"
+ >
+ <input
+ type="checkbox"
+ class="h-4 w-4 accent-blue-500"
+ :checked="isBootstrapSelected(`comm:${iface.name}`)"
+ @change="toggleBootstrap(`comm:${iface.name}`)"
+ />
+ <v-icon icon="mdi-server-network" color="blue" size="22"></v-icon>
+ <div class="min-w-0 flex-1">
+ <div class="truncate text-sm font-bold text-gray-900 dark:text-white">
+ {{ iface.name }}
+ </div>
+ <div
+ class="truncate font-mono text-[10px] text-gray-500 dark:text-zinc-400"
+ >
+ {{ iface.target_host
+ }}<span v-if="iface.target_port">:{{ iface.target_port }}</span>
+ </div>
+ </div>
+ <span
+ v-if="iface.online"
+ class="shrink-0 text-[9px] font-bold uppercase tracking-widest text-green-500"
+ >{{ $t("tutorial.online") }}</span
+ >
+ </label>
+ <div v-if="loadingInterfaces" class="flex justify-center py-3">
+ <v-progress-circular
+ indeterminate
+ color="blue"
+ size="24"
+ ></v-progress-circular>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex flex-col sm:flex-row items-center justify-between gap-4 max-w-6xl mx-auto pt-4"
+ >
+ <p class="text-sm text-gray-500 dark:text-zinc-500">
+ {{
+ $t("tutorial.bootstrap_selected", {
+ count: selectedBootstrapCount,
+ })
+ }}
+ </p>
+ <div class="flex gap-3">
+ <button
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-secondary"
+ @click="skipBootstraps"
+ >
+ {{ $t("tutorial.bootstrap_skip") }}
+ </button>
+ <button
+ type="button"
+ class="tutorial-action-btn tutorial-action-btn-success"
+ :disabled="addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0"
+ @click="confirmBootstraps"
+ >
+ <v-progress-circular
+ v-if="addingBootstraps || reloadingReticulum"
+ indeterminate
+ size="16"
+ width="2"
+ class="mr-2"
+ ></v-progress-circular>
+ {{ $t("tutorial.bootstrap_confirm") }}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <!-- Step 5: Propagation Mode -->
+ <div v-else-if="currentStep === 5" key="page-step5-prop" class="space-y-8 py-12">
<div class="text-center space-y-4">
<h2 class="text-4xl font-black text-gray-900 dark:text-white">
{{ $t("tutorial.propagation") }}
@@ -1285,8 +1944,8 @@
</div>
</div>
- <!-- Step 5: Learn & Create -->
- <div v-else-if="currentStep === 5" key="page-step5-tools" class="space-y-8 py-10">
+ <!-- Step 6: Learn & Create -->
+ <div v-else-if="currentStep === 6" key="page-step6-tools" class="space-y-8 py-10">
<div class="text-center space-y-4">
<h2 class="text-4xl font-black text-gray-900 dark:text-white">
{{ $t("tutorial.learn_create") }}
@@ -1539,10 +2198,10 @@
</div>
</div>
- <!-- Step 6: Finish -->
+ <!-- Step 7: Finish -->
<div
- v-else-if="currentStep === 6"
- key="page-step6-finish"
+ v-else-if="currentStep === 7"
+ key="page-step7-finish"
class="flex flex-col items-center text-center space-y-10 py-20"
>
<div class="w-48 h-48 bg-green-500/10 rounded-full flex items-center justify-center relative">
@@ -1631,18 +2290,20 @@ import GlobalState from "../js/GlobalState";
import { bundledReticulumDocsUrl } from "../js/reticulumDocsEntryUrl.js";
import LanguageSelector from "./LanguageSelector.vue";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
+import Toggle from "./forms/Toggle.vue";
export default {
name: "TutorialModal",
components: {
LanguageSelector,
MaterialDesignIcon,
+ Toggle,
},
data() {
return {
visible: false,
currentStep: 1,
- totalSteps: 6,
+ totalSteps: 7,
logoUrl,
identityMode: "new",
identityName: "",
@@ -1652,14 +2313,31 @@ export default {
identityImportError: "",
identityImportedHash: null,
originalIdentityHash: null,
+ communityInterfaces: [],
+ loadingInterfaces: false,
finishingTutorial: false,
interfaceAddedViaTutorial: false,
connectionMode: null,
+ selectedBootstrapKeys: [],
+ addedBootstrapKeys: [],
+ addingBootstraps: false,
addingLocal: false,
reloadingReticulum: false,
+ discoveredInterfaces: [],
+ discoveredActive: [],
+ loadingDiscovered: false,
+ savingDiscovery: false,
savingPropagation: false,
+ discoveryInterval: null,
markingSeen: false,
windowWidth: typeof window !== "undefined" ? window.innerWidth : 1024,
+ defaultBootstrapOnly: false,
+ refreshingCommunityPresets: false,
+ bootstrapListSearch: "",
+ bootstrapDiscoveredSectionOpen: true,
+ bootstrapCommunitySectionOpen: true,
+ bootstrapAutoPickDone: false,
+ pickingRandomBootstraps: false,
migrationOffer: null,
migrationBusy: false,
androidStorageSetup: null,
@@ -1678,6 +2356,55 @@ export default {
config() {
return GlobalState.config;
},
+ sortedDiscoveredInterfaces() {
+ return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
+ },
+ interfacesWithLocation() {
+ return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
+ },
+ bootstrapCommunityKey() {
+ return (iface) => `comm:${iface.name}`;
+ },
+ bootstrapDiscoveredKey() {
+ return (iface) => `disc:${iface.discovery_hash || iface.name}`;
+ },
+ hasAnyBootstrapsToShow() {
+ return this.communityInterfaces.length > 0 || this.sortedDiscoveredInterfaces.length > 0;
+ },
+ filteredDiscoveredForBootstrap() {
+ const list = this.sortedDiscoveredInterfaces;
+ const q = (this.bootstrapListSearch || "").trim().toLowerCase();
+ if (!q) {
+ return list;
+ }
+ return list.filter((iface) => {
+ const parts = [
+ iface.name,
+ iface.type,
+ iface.reachable_on,
+ String(iface.port ?? ""),
+ iface.status,
+ iface.discovery_hash,
+ ].filter(Boolean);
+ return parts.join(" ").toLowerCase().includes(q);
+ });
+ },
+ filteredCommunityForBootstrap() {
+ const list = this.communityInterfaces;
+ const q = (this.bootstrapListSearch || "").trim().toLowerCase();
+ if (!q) {
+ return list;
+ }
+ return list.filter((iface) => {
+ const parts = [iface.name, iface.target_host, String(iface.target_port ?? ""), iface.type].filter(
+ Boolean
+ );
+ return parts.join(" ").toLowerCase().includes(q);
+ });
+ },
+ selectedBootstrapCount() {
+ return this.selectedBootstrapKeys.length;
+ },
reticulumBundledDocsUrl() {
return bundledReticulumDocsUrl(this.$i18n.locale);
},
@@ -1687,17 +2414,36 @@ export default {
hasIdentityImportInput() {
return Boolean(this.identityImportFile || this.normalizeBase32(this.identityImportBase32));
},
+ bootstrapSelectedLabels() {
+ return this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k)).filter(Boolean);
+ },
showFooterContinue() {
- if (this.currentStep === 3) {
+ if (this.currentStep === 3 || this.currentStep === 4) {
return false;
}
return this.currentStep < this.totalSteps;
},
},
+ watch: {
+ communityInterfaces() {
+ this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
+ },
+ discoveredInterfaces() {
+ this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
+ },
+ currentStep(val) {
+ if (val === 4) {
+ this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
+ }
+ },
+ },
beforeUnmount() {
if (this.onWindowResize) {
window.removeEventListener("resize", this.onWindowResize);
}
+ if (this.discoveryInterval) {
+ clearInterval(this.discoveryInterval);
+ }
},
mounted() {
this.onWindowResize = () => {
@@ -1706,8 +2452,14 @@ export default {
window.addEventListener("resize", this.onWindowResize, { passive: true });
if (this.isPage) {
this.loadIdentitySetupDefaults();
+ this.loadDiscoveryBootstrapDefaults();
+ this.loadCommunityInterfaces();
+ this.loadDiscoveredInterfaces();
this.refreshMigrationOffer();
this.refreshAndroidStorageSetup();
+ this.discoveryInterval = setInterval(() => {
+ this.loadDiscoveredInterfaces();
+ }, 5000);
}
},
methods: {
@@ -1863,9 +2615,63 @@ export default {
this.resetIdentitySetupState();
this.interfaceAddedViaTutorial = false;
this.connectionMode = null;
+ this.selectedBootstrapKeys = [];
+ this.addedBootstrapKeys = [];
+ this.bootstrapListSearch = "";
+ this.bootstrapDiscoveredSectionOpen = true;
+ this.bootstrapCommunitySectionOpen = true;
+ this.bootstrapAutoPickDone = false;
await this.refreshMigrationOffer();
await this.refreshAndroidStorageSetup();
await this.loadIdentitySetupDefaults();
+ await this.loadDiscoveryBootstrapDefaults();
+ await this.loadCommunityInterfaces();
+ await this.loadDiscoveredInterfaces();
+
+ if (this.discoveryInterval) {
+ clearInterval(this.discoveryInterval);
+ }
+ this.discoveryInterval = setInterval(() => {
+ this.loadDiscoveredInterfaces();
+ }, 5000);
+ },
+ async loadCommunityInterfaces() {
+ this.loadingInterfaces = true;
+ try {
+ const response = await window.api.get("/api/v1/community-interfaces");
+ this.communityInterfaces = response.data.interfaces;
+ } catch (e) {
+ console.error("Failed to load community interfaces:", e);
+ } finally {
+ this.loadingInterfaces = false;
+ }
+ },
+ async refreshCommunityPresets() {
+ if (this.refreshingCommunityPresets) return;
+ this.refreshingCommunityPresets = true;
+ try {
+ const r = await window.api.post("/api/v1/community-interfaces/refresh", {});
+ const n = r.data?.count ?? 0;
+ ToastUtils.success(this.$t("interfaces.community_presets_refreshed", { count: n }));
+ await this.loadCommunityInterfaces();
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("interfaces.community_presets_refresh_failed"));
+ console.error(e);
+ } finally {
+ this.refreshingCommunityPresets = false;
+ }
+ },
+ async loadDiscoveredInterfaces() {
+ this.loadingDiscovered = true;
+ try {
+ const response = await window.api.get(`/api/v1/reticulum/discovered-interfaces`);
+ this.discoveredInterfaces = response.data?.interfaces ?? [];
+ this.discoveredActive = response.data?.active ?? [];
+ } catch (e) {
+ console.error("Failed to load discovered interfaces:", e);
+ } finally {
+ this.loadingDiscovered = false;
+ }
},
async refreshMigrationOffer() {
this.migrationOffer = null;
@@ -1967,6 +2773,32 @@ export default {
this.reloadingReticulum = false;
}
},
+ async useDiscoveryMode() {
+ this.savingDiscovery = true;
+ try {
+ const payload = {
+ discover_interfaces: true,
+ autoconnect_discovered_interfaces: 4,
+ default_bootstrap_only: false,
+ };
+ await window.api.patch(`/api/v1/reticulum/discovery`, payload);
+ this.defaultBootstrapOnly = false;
+ ToastUtils.success(this.$t("tutorial.discovery_enabled"));
+ this.connectionMode = "discovery";
+ this.currentStep = 4;
+ this.bootstrapListSearch = "";
+ this.bootstrapDiscoveredSectionOpen = true;
+ this.bootstrapCommunitySectionOpen = true;
+ await this.loadCommunityInterfaces();
+ await this.loadDiscoveredInterfaces();
+ await this.maybeAutoPickBootstrapTcp();
+ } catch (e) {
+ console.error("Failed to enable discovery:", e);
+ ToastUtils.error(this.$t("tutorial.failed_enable_discovery"));
+ } finally {
+ this.savingDiscovery = false;
+ }
+ },
async useLocalMode() {
if (this.addingLocal) return;
this.addingLocal = true;
@@ -1985,7 +2817,7 @@ export default {
return;
}
this.connectionMode = "local";
- this.currentStep = 4;
+ this.currentStep = 5;
} catch (e) {
console.error("Failed to add AutoInterface:", e);
ToastUtils.error(e.response?.data?.message || this.$t("tutorial.failed_add_local"));
@@ -1995,7 +2827,295 @@ export default {
},
useManualMode() {
this.connectionMode = "manual";
- this.currentStep = 4;
+ this.currentStep = 5;
+ },
+ isBootstrapSelected(key) {
+ return this.selectedBootstrapKeys.includes(key);
+ },
+ toggleBootstrap(key) {
+ const idx = this.selectedBootstrapKeys.indexOf(key);
+ if (idx >= 0) {
+ this.selectedBootstrapKeys.splice(idx, 1);
+ } else {
+ this.selectedBootstrapKeys.push(key);
+ }
+ },
+ bootstrapDisplayLabelForKey(key) {
+ if (!key) {
+ return "";
+ }
+ if (key.startsWith("comm:")) {
+ const name = key.slice(5);
+ const iface = this.communityInterfaces.find((c) => c.name === name);
+ return iface?.name || name;
+ }
+ if (key.startsWith("disc:")) {
+ const suffix = key.slice(5);
+ const iface = this.discoveredInterfaces.find((d) => String(d.discovery_hash || d.name) === suffix);
+ return iface?.name || suffix;
+ }
+ return key;
+ },
+ communityBootstrapExcludedFromRandom(iface) {
+ const name = String(iface.name || "");
+ const desc = String(iface.description || "");
+ const host = String(iface.target_host || "").trim();
+ const hay = `${name} ${desc}`.toLowerCase();
+ if (hay.includes("yggdrasil")) {
+ return true;
+ }
+ if (/\bygg\b/.test(hay) || hay.includes("-ygg") || hay.includes(" ygg") || hay.includes("(ygg")) {
+ return true;
+ }
+ if (/^(200|201|202|203):[0-9a-f:]+$/i.test(host)) {
+ return true;
+ }
+ return false;
+ },
+ pickEligibleCommunityTcpBootstrapForRandom() {
+ const out = [];
+ for (const iface of this.communityInterfaces) {
+ const t = iface.type;
+ if (t !== "TCPClientInterface" && t !== "BackboneInterface") {
+ continue;
+ }
+ const host = String(iface.target_host || "").trim();
+ const port = iface.target_port;
+ if (!host || port === undefined || port === null || port === "") {
+ continue;
+ }
+ if (this.communityBootstrapExcludedFromRandom(iface)) {
+ continue;
+ }
+ out.push({
+ key: `comm:${iface.name}`,
+ kind: "community",
+ iface,
+ dedupe: `${host.toLowerCase()}:${Number(port)}`,
+ });
+ }
+ return out;
+ },
+ pickEligibleTcpBootstrapEntries() {
+ const out = [];
+ for (const iface of this.communityInterfaces) {
+ const t = iface.type;
+ if (t !== "TCPClientInterface" && t !== "BackboneInterface") {
+ continue;
+ }
+ const host = String(iface.target_host || "").trim();
+ const port = iface.target_port;
+ if (!host || port === undefined || port === null || port === "") {
+ continue;
+ }
+ out.push({
+ key: `comm:${iface.name}`,
+ kind: "community",
+ iface,
+ dedupe: `${host.toLowerCase()}:${Number(port)}`,
+ });
+ }
+ for (const iface of this.discoveredInterfaces) {
+ const host = String(iface.reachable_on || "").trim();
+ const port = iface.port;
+ if (!host || port === undefined || port === null || port === "") {
+ continue;
+ }
+ const typ = iface.type || "";
+ if (typ && typ !== "BackboneInterface" && typ !== "TCPClientInterface") {
+ continue;
+ }
+ out.push({
+ key: `disc:${iface.discovery_hash || iface.name}`,
+ kind: "discovered",
+ iface,
+ dedupe: `${host.toLowerCase()}:${Number(port)}`,
+ });
+ }
+ return out;
+ },
+ dedupeBootstrapEntries(entries) {
+ const seen = new Set();
+ const deduped = [];
+ for (const e of entries) {
+ if (seen.has(e.dedupe)) {
+ continue;
+ }
+ seen.add(e.dedupe);
+ deduped.push(e);
+ }
+ return deduped;
+ },
+ shuffleArrayInPlace(arr) {
+ for (let i = arr.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [arr[i], arr[j]] = [arr[j], arr[i]];
+ }
+ },
+ async pickRandomTcpBootstraps(options = {}) {
+ const silent = options.silent === true;
+ const auto = options.auto === true;
+ if (!silent && !auto) {
+ this.pickingRandomBootstraps = true;
+ }
+ await Promise.resolve();
+ await new Promise((resolve) => {
+ if (typeof requestAnimationFrame !== "undefined") {
+ requestAnimationFrame(() => resolve());
+ } else {
+ setTimeout(resolve, 0);
+ }
+ });
+ try {
+ let entries = this.pickEligibleCommunityTcpBootstrapForRandom();
+ entries = this.dedupeBootstrapEntries(entries);
+ if (entries.length === 0) {
+ if (!silent && !auto) {
+ ToastUtils.warning(this.$t("tutorial.bootstrap_pick_random_none"));
+ }
+ return;
+ }
+ this.shuffleArrayInPlace(entries);
+ const take = Math.min(4, entries.length);
+ this.selectedBootstrapKeys = entries.slice(0, take).map((e) => e.key);
+ const labels = this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k));
+ if (!silent && !auto) {
+ ToastUtils.success(
+ this.$t("tutorial.bootstrap_pick_random_done", {
+ count: take,
+ names: labels.join(", "),
+ })
+ );
+ }
+ } finally {
+ if (!silent && !auto) {
+ this.pickingRandomBootstraps = false;
+ }
+ }
+ },
+ async maybeAutoPickBootstrapTcp() {
+ if (this.bootstrapAutoPickDone) {
+ return;
+ }
+ if (this.currentStep !== 4 || this.connectionMode !== "discovery") {
+ return;
+ }
+ if (this.selectedBootstrapKeys.length > 0) {
+ return;
+ }
+ const entries = this.dedupeBootstrapEntries(this.pickEligibleCommunityTcpBootstrapForRandom());
+ if (entries.length === 0) {
+ return;
+ }
+ await this.pickRandomTcpBootstraps({ silent: true, auto: true });
+ this.bootstrapAutoPickDone = true;
+ },
+ buildBootstrapPayload(item) {
+ if (item.kind === "discovered") {
+ const iface = item.iface;
+ const payload = {
+ name: iface.name || `Discovered ${iface.discovery_hash || ""}`.trim(),
+ type: iface.type === "BackboneInterface" ? "TCPClientInterface" : iface.type,
+ enabled: true,
+ bootstrap_only: this.defaultBootstrapOnly === true,
+ };
+ if (iface.reachable_on) {
+ payload.target_host = iface.reachable_on;
+ }
+ if (iface.port) {
+ payload.target_port = iface.port;
+ }
+ return payload;
+ }
+ const iface = item.iface;
+ return {
+ name: iface.name,
+ type: iface.type,
+ target_host: iface.target_host,
+ target_port: iface.target_port,
+ enabled: true,
+ bootstrap_only: this.defaultBootstrapOnly === true,
+ };
+ },
+ parseDiscoveryBool(value, defaultValue = false) {
+ if (value === undefined || value === null || value === "") {
+ return defaultValue;
+ }
+ if (typeof value === "string") {
+ return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
+ }
+ return Boolean(value);
+ },
+ async loadDiscoveryBootstrapDefaults() {
+ try {
+ const response = await window.api.get("/api/v1/reticulum/discovery");
+ const d = response.data?.discovery ?? {};
+ this.defaultBootstrapOnly = this.parseDiscoveryBool(d.default_bootstrap_only, false);
+ } catch (e) {
+ console.error(e);
+ this.defaultBootstrapOnly = false;
+ }
+ },
+ async persistDefaultBootstrapOnly(value) {
+ try {
+ await window.api.patch("/api/v1/reticulum/discovery", {
+ default_bootstrap_only: value === true,
+ });
+ this.defaultBootstrapOnly = value === true;
+ } catch (e) {
+ console.error("Failed to save default_bootstrap_only:", e);
+ ToastUtils.error(this.$t("tutorial.failed_save_bootstrap_only"));
+ this.defaultBootstrapOnly = !value;
+ }
+ },
+ async confirmBootstraps() {
+ if (this.addingBootstraps) return;
+ if (this.selectedBootstrapKeys.length === 0) {
+ ToastUtils.warning(this.$t("tutorial.bootstrap_pick_at_least_one"));
+ return;
+ }
+ this.addingBootstraps = true;
+ const items = [];
+ for (const key of this.selectedBootstrapKeys) {
+ if (this.addedBootstrapKeys.includes(key)) continue;
+ if (key.startsWith("comm:")) {
+ const iface = this.communityInterfaces.find((c) => `comm:${c.name}` === key);
+ if (iface) items.push({ key, kind: "community", iface });
+ } else if (key.startsWith("disc:")) {
+ const iface = this.discoveredInterfaces.find((d) => `disc:${d.discovery_hash || d.name}` === key);
+ if (iface) items.push({ key, kind: "discovered", iface });
+ }
+ }
+ let added = 0;
+ for (const item of items) {
+ try {
+ const payload = this.buildBootstrapPayload(item);
+ if (!payload.target_host) continue;
+ await window.api.post("/api/v1/reticulum/interfaces/add", payload);
+ this.addedBootstrapKeys.push(item.key);
+ GlobalState.hasPendingInterfaceChanges = true;
+ GlobalState.modifiedInterfaceNames.add(payload.name);
+ added += 1;
+ } catch (e) {
+ console.error("Failed to add bootstrap interface:", e);
+ ToastUtils.error(e.response?.data?.message || this.$t("tutorial.failed_add_bootstrap"));
+ }
+ }
+ if (added === 0) {
+ ToastUtils.warning(this.$t("tutorial.failed_add_bootstrap_none"));
+ this.addingBootstraps = false;
+ return;
+ }
+ this.interfaceAddedViaTutorial = true;
+ ToastUtils.success(this.$t("tutorial.bootstrap_added", { count: added }));
+ const reloaded = await this.reloadReticulum();
+ this.addingBootstraps = false;
+ if (reloaded) {
+ this.currentStep = 5;
+ }
+ },
+ skipBootstraps() {
+ this.currentStep = 5;
},
async enableAutoPropagation() {
this.savingPropagation = true;
@@ -2015,6 +3135,55 @@ export default {
this.savingPropagation = false;
}
},
+ getDiscoveryIcon(iface) {
+ switch (iface.type) {
+ case "AutoInterface":
+ return "home-automation";
+ case "RNodeInterface":
+ return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
+ case "RNodeMultiInterface":
+ return "access-point-network";
+ case "TCPClientInterface":
+ case "BackboneInterface":
+ return "lan-connect";
+ case "TCPServerInterface":
+ return "lan";
+ case "UDPInterface":
+ return "wan";
+ case "SerialInterface":
+ return "usb-port";
+ case "KISSInterface":
+ case "AX25KISSInterface":
+ return "antenna";
+ case "I2PInterface":
+ return "eye";
+ case "PipeInterface":
+ return "pipe";
+ default:
+ return "server-network";
+ }
+ },
+ formatLastHeard(ts) {
+ const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
+ if (seconds < 60) return `${seconds}s ago`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
+ return `${Math.floor(seconds / 86400)}d ago`;
+ },
+ copyToClipboard(text, label) {
+ if (!text) return;
+ navigator.clipboard.writeText(text);
+ ToastUtils.success(`${label} copied to clipboard`);
+ },
+ mapAllDiscovered() {
+ if (!this.isPage) {
+ this.visible = false;
+ }
+ this.$router.push({
+ name: "map",
+ query: { view: "discovered" },
+ });
+ },
gotoAddInterface() {
void this.closeWithPendingImportGuard().then((closed) => {
if (!closed) {
@@ -2066,12 +3235,25 @@ export default {
ToastUtils.warning(this.$t("tutorial.connect_mode_required"));
return;
}
+ if (this.connectionMode !== "discovery") {
+ this.currentStep = 5;
+ return;
+ }
+ }
+ if (this.currentStep === 4) {
+ ToastUtils.warning(this.$t("tutorial.bootstrap_pick_at_least_one"));
+ return;
}
this.currentStep++;
+ if (this.currentStep === 4) {
+ this.bootstrapListSearch = "";
+ this.bootstrapDiscoveredSectionOpen = true;
+ this.bootstrapCommunitySectionOpen = true;
+ }
},
previousStep() {
if (this.currentStep <= 1) return;
- if (this.currentStep === 4) {
+ if (this.currentStep === 5 && this.connectionMode !== "discovery") {
this.currentStep = 3;
return;
}
diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
index f0124771..6844d12f 100644
--- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
+++ b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
@@ -18,29 +18,36 @@
<button
type="button"
class="primary-chip inline-flex items-center gap-2 px-4 py-2 text-sm"
- :disabled="isLoading"
+ :disabled="isLoading || reloadingRns"
@click="refreshStatus"
>
<MaterialDesignIcon
icon-name="refresh"
class="h-4 w-4 shrink-0"
- :class="{ 'animate-spin-reverse': isLoading }"
+ :class="{ 'animate-spin-reverse': isLoading || reloadingRns }"
/>
- Refresh
+ {{ reloadingRns ? $t("rnstatus.reloading") : $t("rnstatus.refresh") }}
</button>
<label class="secondary-chip inline-flex cursor-pointer items-center gap-2 px-4 py-2 text-sm">
- <input v-model="includeLinkStats" type="checkbox" class="rounded-sm" />
- <span>Include Link Stats</span>
+ <input
+ v-model="includeLinkStats"
+ type="checkbox"
+ class="rounded-sm"
+ :disabled="reloadingRns"
+ />
+ <span>{{ $t("rnstatus.include_link_stats") }}</span>
</label>
<div class="flex min-w-0 flex-wrap items-center gap-2">
- <label class="shrink-0 text-sm text-gray-700 dark:text-gray-300">Sort by:</label>
- <select v-model="sorting" class="input-field min-w-40 text-sm">
- <option value="">None</option>
- <option value="bitrate">Bitrate</option>
- <option value="rx">RX Bytes</option>
- <option value="tx">TX Bytes</option>
- <option value="traffic">Total Traffic</option>
- <option value="announces">Announces</option>
+ <label class="shrink-0 text-sm text-gray-700 dark:text-gray-300">{{
+ $t("rnstatus.sort_by")
+ }}</label>
+ <select v-model="sorting" class="input-field min-w-40 text-sm" :disabled="reloadingRns">
+ <option value="">{{ $t("rnstatus.none") }}</option>
+ <option value="bitrate">{{ $t("rnstatus.bitrate") }}</option>
+ <option value="rx">{{ $t("rnstatus.rx_bytes") }}</option>
+ <option value="tx">{{ $t("rnstatus.tx_bytes") }}</option>
+ <option value="traffic">{{ $t("rnstatus.total_traffic") }}</option>
+ <option value="announces">{{ $t("rnstatus.announces") }}</option>
</select>
</div>
</div>
@@ -50,7 +57,9 @@
v-if="linkCount !== null"
class="rounded-xl border border-blue-200/80 bg-blue-50/90 p-4 text-blue-800 dark:border-blue-800/50 dark:bg-blue-950/30 dark:text-blue-200"
>
- <div class="text-sm font-semibold">Active Links: {{ formatInt(linkCount) }}</div>
+ <div class="text-sm font-semibold">
+ {{ $t("rnstatus.active_links", { count: formatInt(linkCount) }) }}
+ </div>
</div>
<div
@@ -90,10 +99,10 @@
</div>
<div
- v-if="interfaces.length === 0 && !isLoading"
+ v-if="interfaces.length === 0 && !isLoading && !reloadingRns"
class="glass-card p-8 text-center text-gray-500 dark:text-gray-400"
>
- No interfaces found. Click refresh to load status.
+ {{ $t("rnstatus.no_interfaces_found") }}
</div>
<div
@@ -133,35 +142,35 @@
<div class="grid gap-x-6 gap-y-4 p-4 text-sm sm:p-5 md:grid-cols-2 lg:grid-cols-3">
<div v-if="iface.mode">
- <div class="text-gray-500 dark:text-gray-400">Mode</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.mode") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.mode }}</div>
</div>
<div v-if="iface.bitrate">
- <div class="text-gray-500 dark:text-gray-400">Bitrate</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.bitrate") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.bitrate }}</div>
</div>
<div v-if="iface.rx_bytes_str">
- <div class="text-gray-500 dark:text-gray-400">RX Bytes</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.rx_bytes") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.rx_bytes_str }}</div>
</div>
<div v-if="iface.tx_bytes_str">
- <div class="text-gray-500 dark:text-gray-400">TX Bytes</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.tx_bytes") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.tx_bytes_str }}</div>
</div>
<div v-if="iface.rx_packets !== undefined">
- <div class="text-gray-500 dark:text-gray-400">RX Packets</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.rx_packets") }}</div>
<div class="font-semibold tabular-nums text-gray-900 dark:text-white">
{{ iface.rx_packets }}
</div>
</div>
<div v-if="iface.tx_packets !== undefined">
- <div class="text-gray-500 dark:text-gray-400">TX Packets</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.tx_packets") }}</div>
<div class="font-semibold tabular-nums text-gray-900 dark:text-white">
{{ iface.tx_packets }}
</div>
</div>
<div v-if="iface.clients !== undefined">
- <div class="text-gray-500 dark:text-gray-400">Clients</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.clients") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ formatInt(iface.clients) }}
</div>
@@ -169,31 +178,31 @@
<div v-if="iface.peers !== undefined">
<div class="text-gray-500 dark:text-gray-400">Peers</div>
<div class="font-semibold text-gray-900 dark:text-white">
- {{ formatInt(iface.peers) }} reachable
+ {{ formatInt(iface.peers) }} {{ $t("rnstatus.peers_reachable") }}
</div>
</div>
<div v-if="iface.noise_floor">
- <div class="text-gray-500 dark:text-gray-400">Noise Floor</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.noise_floor") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.noise_floor }}</div>
</div>
<div v-if="iface.interference">
- <div class="text-gray-500 dark:text-gray-400">Interference</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.interference") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.interference }}</div>
</div>
<div v-if="iface.cpu_load">
- <div class="text-gray-500 dark:text-gray-400">CPU Load</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.cpu_load") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.cpu_load }}</div>
</div>
<div v-if="iface.cpu_temp">
- <div class="text-gray-500 dark:text-gray-400">CPU Temp</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.cpu_temp") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.cpu_temp }}</div>
</div>
<div v-if="iface.mem_load">
- <div class="text-gray-500 dark:text-gray-400">Memory Load</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.memory_load") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.mem_load }}</div>
</div>
<div v-if="iface.battery_percent !== undefined">
- <div class="text-gray-500 dark:text-gray-400">Battery</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.battery") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ formatInt(iface.battery_percent) }}%<span v-if="iface.battery_state">
({{ iface.battery_state }})</span
@@ -201,29 +210,33 @@
</div>
</div>
<div v-if="iface.network_name">
- <div class="text-gray-500 dark:text-gray-400">Network</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.network") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.network_name }}</div>
</div>
<div v-if="iface.incoming_announce_frequency !== undefined">
- <div class="text-gray-500 dark:text-gray-400">Incoming Announces</div>
+ <div class="text-gray-500 dark:text-gray-400">
+ {{ $t("rnstatus.incoming_announces") }}
+ </div>
<div class="font-semibold tabular-nums text-gray-900 dark:text-white">
{{ iface.incoming_announce_frequency }}/s
</div>
</div>
<div v-if="iface.outgoing_announce_frequency !== undefined">
- <div class="text-gray-500 dark:text-gray-400">Outgoing Announces</div>
+ <div class="text-gray-500 dark:text-gray-400">
+ {{ $t("rnstatus.outgoing_announces") }}
+ </div>
<div class="font-semibold tabular-nums text-gray-900 dark:text-white">
{{ iface.outgoing_announce_frequency }}/s
</div>
</div>
<div v-if="iface.airtime">
- <div class="text-gray-500 dark:text-gray-400">Airtime</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.airtime") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.airtime.short }}% (15s), {{ iface.airtime.long }}% (1h)
</div>
</div>
<div v-if="iface.channel_load">
- <div class="text-gray-500 dark:text-gray-400">Channel Load</div>
+ <div class="text-gray-500 dark:text-gray-400">{{ $t("rnstatus.channel_load") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.channel_load.short }}% (15s), {{ iface.channel_load.long }}% (1h)
</div>
@@ -238,6 +251,8 @@
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
+import ToastUtils from "../../js/ToastUtils";
+import WebSocketConnection from "../../js/WebSocketConnection";
export default {
name: "RNStatusPage",
@@ -248,6 +263,7 @@ export default {
data() {
return {
isLoading: false,
+ reloadingRns: false,
interfaces: [],
linkCount: null,
includeLinkStats: false,
@@ -259,15 +275,23 @@ export default {
},
watch: {
sorting() {
- this.refreshStatus();
+ if (!this.reloadingRns) {
+ this.refreshStatus();
+ }
},
includeLinkStats() {
- this.refreshStatus();
+ if (!this.reloadingRns) {
+ this.refreshStatus();
+ }
},
},
mounted() {
+ WebSocketConnection.on("message", this.onWebsocketMessage);
this.refreshStatus();
},
+ beforeUnmount() {
+ WebSocketConnection.off("message", this.onWebsocketMessage);
+ },
methods: {
formatInt(value) {
if (value === null || value === undefined) {
@@ -279,7 +303,25 @@ export default {
}
return n.toLocaleString();
},
+ onWebsocketMessage(message) {
+ let json;
+ try {
+ json = typeof message === "string" ? JSON.parse(message) : message;
+ } catch {
+ return;
+ }
+ if (!json || json.type !== "reticulum_reload_status") {
+ return;
+ }
+ this.reloadingRns = json.in_progress !== false;
+ if (json.in_progress === false && json.level !== "error") {
+ this.refreshStatus();
+ }
+ },
async refreshStatus() {
+ if (this.reloadingRns) {
+ return;
+ }
this.isLoading = true;
try {
const params = {
@@ -296,6 +338,10 @@ export default {
this.blackholeCount = response.data.blackhole_count || 0;
} catch (e) {
console.error(e);
+ const detail = e?.response?.data?.message || e?.message || "";
+ ToastUtils.error(
+ detail ? `${this.$t("rnstatus.failed_refresh")}: ${detail}` : this.$t("rnstatus.failed_refresh")
+ );
} finally {
this.isLoading = false;
}
diff --git a/meshchatx/src/frontend/index.html b/meshchatx/src/frontend/index.html
index 139800c5..a662c569 100644
--- a/meshchatx/src/frontend/index.html
+++ b/meshchatx/src/frontend/index.html
@@ -7,6 +7,32 @@
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png" />
<title>Reticulum MeshChatX</title>
+ <script>
+ (function () {
+ try {
+ var theme = null;
+ if (window.MeshChatXAndroid && typeof window.MeshChatXAndroid.getPreferredUiTheme === "function") {
+ theme = window.MeshChatXAndroid.getPreferredUiTheme();
+ }
+ if (!theme) {
+ try {
+ theme = window.localStorage.getItem("meshchatx_ui_theme");
+ } catch (e) {}
+ }
+ if (theme !== "light" && theme !== "dark") {
+ theme = "dark";
+ }
+ if (theme === "dark") {
+ document.documentElement.classList.add("dark");
+ document.documentElement.dataset.bootTheme = "dark";
+ document.documentElement.style.colorScheme = "dark";
+ } else {
+ document.documentElement.dataset.bootTheme = "light";
+ document.documentElement.style.colorScheme = "light";
+ }
+ } catch (e) {}
+ })();
+ </script>
<style>
#meshchatx-boot-splash {
box-sizing: border-box;
@@ -24,15 +50,13 @@
-apple-system,
"Segoe UI",
sans-serif;
+ color: #f1f5f9;
+ background: linear-gradient(160deg, #0f172a 0%, #18181b 50%, #27272a 100%);
+ }
+ html[data-boot-theme="light"] #meshchatx-boot-splash {
color: #0f172a;
background: linear-gradient(160deg, #f1f5f9 0%, #e2e8f0 45%, #cbd5e1 100%);
}
- @media (prefers-color-scheme: dark) {
- #meshchatx-boot-splash {
- color: #f1f5f9;
- background: linear-gradient(160deg, #0f172a 0%, #18181b 50%, #27272a 100%);
- }
- }
#meshchatx-boot-splash[data-state="error"] {
background: #450a0a;
color: #fecaca;
@@ -45,15 +69,13 @@
height: 4.5rem;
padding: 0.4rem;
border-radius: 1rem;
+ background: rgba(24, 24, 27, 0.85);
+ box-shadow: inset 0 0 0 1px rgba(244, 244, 245, 0.12);
+ }
+ html[data-boot-theme="light"] #meshchatx-boot-splash .meshchatx-boot-logo-wrap {
background: rgba(255, 255, 255, 0.72);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
}
- @media (prefers-color-scheme: dark) {
- #meshchatx-boot-splash .meshchatx-boot-logo-wrap {
- background: rgba(24, 24, 27, 0.85);
- box-shadow: inset 0 0 0 1px rgba(244, 244, 245, 0.12);
- }
- }
#meshchatx-boot-splash .meshchatx-boot-logo {
width: 3.25rem;
height: 3.25rem;
@@ -75,13 +97,11 @@
body {
margin: 0;
min-height: 100%;
- background-color: #f8fafc;
+ background-color: #09090b;
}
- @media (prefers-color-scheme: dark) {
- html,
- body {
- background-color: #09090b;
- }
+ html[data-boot-theme="light"],
+ html[data-boot-theme="light"] body {
+ background-color: #f8fafc;
}
#app {
min-height: 100dvh;
diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.js
index f5f90236..ef665861 100644
--- a/meshchatx/src/frontend/js/MarkdownRenderer.js
+++ b/meshchatx/src/frontend/js/MarkdownRenderer.js
@@ -27,6 +27,18 @@ export default class MarkdownRenderer {
return placeholder;
});
+ // Inline code before emphasis so snake_case inside `code` / ``code`` is safe.
+ const inline_codes = [];
+ const pushInline = (code) => {
+ const placeholder = `[[IC${inline_codes.length}]]`;
+ inline_codes.push(
+ `<code class="bg-black/10 dark:bg-white/10 px-1 rounded-sm font-mono text-[0.9em]">${code}</code>`
+ );
+ return placeholder;
+ };
+ text = text.replace(/``([^`]+)``/g, (_m, code) => pushInline(code));
+ text = text.replace(/`([^`]+)`/g, (_m, code) => pushInline(code));
+
// Headers
text = text.replace(/^# (.*)$/gm, '<h1 class="text-xl font-bold mt-4 mb-2"> $1</h1>');
text = text.replace(/^## (.*)$/gm, '<h2 class="text-lg font-bold mt-3 mb-1">$1</h2>');
@@ -46,16 +58,13 @@ export default class MarkdownRenderer {
'<blockquote class="border-l-4 border-gray-300 dark:border-zinc-700 pl-3 py-1 my-2 italic opacity-80">$1</blockquote>'
);
- // Inline code
- text = text.replace(
- /`([^`]+)`/g,
- '<code class="bg-black/10 dark:bg-white/10 px-1 rounded-sm font-mono text-[0.9em]">$1</code>'
- );
-
// Links
text = LinkUtils.renderAllLinks(text);
- // Restore code blocks
+ // Restore inline code then fenced blocks
+ for (let i = 0; i < inline_codes.length; i++) {
+ text = text.replace(`[[IC${i}]]`, inline_codes[i]);
+ }
for (let i = 0; i < code_blocks.length; i++) {
text = text.replace(`[[CB${i}]]`, code_blocks[i]);
}
@@ -119,6 +128,10 @@ export default class MarkdownRenderer {
// eslint-disable-next-line security/detect-unsafe-regex -- bounded fenced block, lazy match
text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, "[Code Block]");
+ // Strip inline code (double then single)
+ text = text.replace(/``([^`]+)``/g, "$1");
+ text = text.replace(/`([^`]+)`/g, "$1");
+
// Strip headers
text = text.replace(/^#+ (.*)$/gm, "$1");
@@ -130,9 +143,6 @@ export default class MarkdownRenderer {
text = text.replace(/(^|[^\w])__(.*?)__(?=[^\w]|$)/g, "$1$2");
text = text.replace(/(^|[^\w])_(.*?)_(?=[^\w]|$)/g, "$1$2");
- // Strip inline code
- text = text.replace(/`([^`]+)`/g, "$1");
-
return text;
}
}
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index d66991b3..32cd2012 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -2688,7 +2688,9 @@
"channel_load": "Kanallast",
"blackhole_label": "Blackhole: {state}",
"blackhole_publishing": "Veröffentlicht",
- "blackhole_inactive": "Inaktiv"
+ "blackhole_inactive": "Inaktiv",
+ "failed_refresh": "RNStatus konnte nicht aktualisiert werden",
+ "reloading": "RNS wird neu geladen..."
},
"translator": {
"text_translation": "Textübersetzung",
@@ -3003,7 +3005,7 @@
"mode_change_later": "Sie können die Verbindungseinstellungen jederzeit in den Reticulum-Einstellungen ändern.",
"bootstrap_title": "Bootstrap-Knoten auswählen",
"bootstrap_desc": "Bootstrap-Knoten geben der Erkennung einen Startpunkt. Wählen Sie einen oder mehrere und fahren Sie fort.",
- "bootstrap_pick_random_tcp": "3 zufällige TCP-Knoten wählen",
+ "bootstrap_pick_random_tcp": "4 zufällige TCP-Knoten wählen",
"bootstrap_pick_random_none": "Keine geeigneten TCP-Community-Presets. Presets aktualisieren oder manuell wählen (Zufall nutzt nur Community, nicht Erkennung).",
"bootstrap_pick_random_done": "{count} Bootstrap-Knoten ausgewählt: {names}",
"bootstrap_selected_nodes_heading": "Ausgewählte Knoten",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "Starten Sie die App neu, um Ihre Speicherwahl zu übernehmen.",
"failed": "Speicherort konnte nicht aktualisiert werden."
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 6b37b76c..1fd625a3 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -2986,7 +2986,9 @@
"channel_load": "Channel Load",
"blackhole_label": "Blackhole: {state}",
"blackhole_publishing": "Publishing",
- "blackhole_inactive": "Inactive"
+ "blackhole_inactive": "Inactive",
+ "failed_refresh": "Failed to refresh RNStatus",
+ "reloading": "Reloading RNS..."
},
"translator": {
"text_translation": "Text Translation",
@@ -3377,7 +3379,7 @@
"mode_change_later": "You can change connection settings any time from Reticulum settings.",
"bootstrap_title": "Pick Bootstrap Nodes",
"bootstrap_desc": "Bootstrap nodes give discovery a starting point. Select one or more, then continue.",
- "bootstrap_pick_random_tcp": "Pick 3 random TCP nodes",
+ "bootstrap_pick_random_tcp": "Pick 4 random TCP nodes",
"bootstrap_pick_random_none": "No suitable TCP community presets are available. Refresh community presets or pick nodes manually (discovered lists are not used for random pick).",
"bootstrap_pick_random_done": "Selected {count}: {names}",
"bootstrap_selected_nodes_heading": "Selected nodes",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 0dba72e1..1114a881 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -2805,7 +2805,9 @@
"channel_load": "Carga de Canal",
"blackhole_label": "Agujero negro: {state}",
"blackhole_publishing": "Publicando",
- "blackhole_inactive": "Inactivo"
+ "blackhole_inactive": "Inactivo",
+ "failed_refresh": "Error al actualizar RNStatus",
+ "reloading": "Recargando RNS..."
},
"translator": {
"text_translation": "Traducción de texto",
@@ -3172,7 +3174,7 @@
"mode_change_later": "Puede cambiar la configuración de conexión en cualquier momento desde la configuración de Reticulum.",
"bootstrap_title": "Seleccionar Nodos Bootstrap",
"bootstrap_desc": "Los nodos de bootstrap dan a descubrimiento un punto de partida. Seleccione uno o más, luego continúe.",
- "bootstrap_pick_random_tcp": "Elegir 3 nodos TCP aleatorios",
+ "bootstrap_pick_random_tcp": "Elegir 4 nodos TCP aleatorios",
"bootstrap_pick_random_none": "No hay presets TCP comunitarios adecuados. Actualice los presets o elija a mano (el azar solo usa la lista comunitaria, no descubiertos).",
"bootstrap_pick_random_done": "Seleccionados {count}: {names}",
"bootstrap_selected_nodes_heading": "Nodos seleccionados",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "Reinicia la aplicación para aplicar tu elección de almacenamiento.",
"failed": "No se pudo actualizar la ubicación de almacenamiento."
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 0b58bd7a..dd370502 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -2986,7 +2986,9 @@
"channel_load": "Kanavakuorma",
"blackhole_label": "Musta aukko: {state}",
"blackhole_publishing": "Julkaistaan",
- "blackhole_inactive": "Epäaktiivinen"
+ "blackhole_inactive": "Epäaktiivinen",
+ "failed_refresh": "RNStatusin päivitys epäonnistui",
+ "reloading": "Ladataan RNS uudelleen..."
},
"translator": {
"text_translation": "Tekstin käännös",
@@ -3370,7 +3372,7 @@
"mode_change_later": "Voit muuttaa yhteysasetuksia milloin tahansa Reticulum-asetuksista.",
"bootstrap_title": "Valitse bootstrap-solmut",
"bootstrap_desc": "Bootstrap-solmut antavat löydölle lähtökohdan. Valitse yksi tai useampi ja jatka.",
- "bootstrap_pick_random_tcp": "Valitse 3 satunnaista TCP-solmua",
+ "bootstrap_pick_random_tcp": "Valitse 4 satunnaista TCP-solmua",
"bootstrap_pick_random_none": "Sopivia TCP-yhteisön esiasetuksia ei ole saatavilla. Päivitä yhteisön esiasetukset tai valitse solmut manuaalisesti (löydettyjä listoja ei käytetä satunnaisvalintaan).",
"bootstrap_pick_random_done": "Valittu {count}: {names}",
"bootstrap_selected_nodes_heading": "Valitut solmut",
@@ -3470,4 +3472,4 @@
"action_changelog": "Muutosloki",
"action_changelog_desc": "Viimeaikaiset muutokset"
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 1628177e..c48ce274 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -2805,7 +2805,9 @@
"channel_load": "Chargement du canal",
"blackhole_label": "Trou noir : {state}",
"blackhole_publishing": "Publication",
- "blackhole_inactive": "Inactif"
+ "blackhole_inactive": "Inactif",
+ "failed_refresh": "Échec de l'actualisation de RNStatus",
+ "reloading": "Rechargement de RNS..."
},
"translator": {
"text_translation": "Traduction textuelle",
@@ -3172,7 +3174,7 @@
"mode_change_later": "Vous pouvez modifier les paramètres de connexion à tout moment à partir des paramètres de Reticulum.",
"bootstrap_title": "Choisir des nœuds de bootstrap",
"bootstrap_desc": "Les nœuds de bootstrap donnent un point de départ à la découverte. Sélectionnez un ou plusieurs, puis continuez.",
- "bootstrap_pick_random_tcp": "Choisir 3 nœuds TCP au hasard",
+ "bootstrap_pick_random_tcp": "Choisir 4 nœuds TCP au hasard",
"bootstrap_pick_random_none": "Aucun préréglage TCP communautaire adapté. Actualisez les préréglages ou choisissez à la main (le tirage n'utilise que la liste communautaire, pas la découverte).",
"bootstrap_pick_random_done": "{count} sélectionné(s) : {names}",
"bootstrap_selected_nodes_heading": "Nœuds sélectionnés",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "Redémarrez l'application pour appliquer votre choix de stockage.",
"failed": "Impossible de mettre à jour l'emplacement de stockage."
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 846cd263..1e432581 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -2857,7 +2857,9 @@
"channel_load": "Carico Canale",
"blackhole_label": "Buco nero: {state}",
"blackhole_publishing": "Pubblicazione",
- "blackhole_inactive": "Inattivo"
+ "blackhole_inactive": "Inattivo",
+ "failed_refresh": "Impossibile aggiornare RNStatus",
+ "reloading": "Ricaricamento RNS..."
},
"translator": {
"text_translation": "Traduzione Testo",
@@ -3172,7 +3174,7 @@
"mode_change_later": "Puoi cambiare le impostazioni di connessione in qualsiasi momento dalle impostazioni Reticulum.",
"bootstrap_title": "Scegli nodi di bootstrap",
"bootstrap_desc": "I nodi di bootstrap danno alla scoperta un punto di partenza. Selezionane uno o più e continua.",
- "bootstrap_pick_random_tcp": "Scegli 3 nodi TCP casuali",
+ "bootstrap_pick_random_tcp": "Scegli 4 nodi TCP casuali",
"bootstrap_pick_random_none": "Nessun preset TCP della community adatto. Aggiorna i preset o scegli manualmente (la selezione casuale usa solo la community, non il rilevamento).",
"bootstrap_pick_random_done": "Selezionati {count}: {names}",
"bootstrap_selected_nodes_heading": "Nodi selezionati",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "Riavvia l'app per applicare la scelta di archiviazione.",
"failed": "Impossibile aggiornare la posizione di archiviazione."
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index dba8b7c8..2f042fa4 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -2805,7 +2805,9 @@
"channel_load": "Kanaal laden",
"blackhole_label": "Zwart gat: {state}",
"blackhole_publishing": "Publiceren",
- "blackhole_inactive": "Inactief"
+ "blackhole_inactive": "Inactief",
+ "failed_refresh": "RNStatus vernieuwen mislukt",
+ "reloading": "RNS opnieuw laden..."
},
"translator": {
"text_translation": "Tekstvertaling",
@@ -3172,7 +3174,7 @@
"mode_change_later": "U kunt de verbindingsinstellingen op elk moment wijzigen vanuit de instellingen van Reticulum.",
"bootstrap_title": "Kies Bootstrap-nodes",
"bootstrap_desc": "Bootstrap knooppunten geven ontdekking een startpunt. Selecteer één of meer, ga dan verder.",
- "bootstrap_pick_random_tcp": "Kies 3 willekeurige TCP-knooppunten",
+ "bootstrap_pick_random_tcp": "Kies 4 willekeurige TCP-knooppunten",
"bootstrap_pick_random_none": "Geen geschikte TCP-community-presets. Vernieuw presets of kies handmatig (willekeur kiest alleen community, niet ontdekking).",
"bootstrap_pick_random_done": "{count} geselecteerd: {names}",
"bootstrap_selected_nodes_heading": "Geselecteerde knooppunten",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "Herstart de app om uw opslagkeuze toe te passen.",
"failed": "Kan opslaglocatie niet bijwerken."
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 679b3a33..6b622d77 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -2688,7 +2688,9 @@
"channel_load": "Загрузка канала",
"blackhole_label": "Чёрная дыра: {state}",
"blackhole_publishing": "Публикация",
- "blackhole_inactive": "Неактивно"
+ "blackhole_inactive": "Неактивно",
+ "failed_refresh": "Не удалось обновить RNStatus",
+ "reloading": "Перезагрузка RNS..."
},
"translator": {
"text_translation": "Перевод текста",
@@ -3003,7 +3005,7 @@
"mode_change_later": "Настройки подключения можно изменить в любое время в параметрах Reticulum.",
"bootstrap_title": "Выберите начальные узлы",
"bootstrap_desc": "Начальные узлы дают обнаружению точку входа. Выберите один или несколько и продолжайте.",
- "bootstrap_pick_random_tcp": "Выбрать 3 случайных TCP-узла",
+ "bootstrap_pick_random_tcp": "Выбрать 4 случайных TCP-узла",
"bootstrap_pick_random_none": "Нет подходящих TCP-пресетов сообщества. Обновите пресеты или выберите вручную (случайный выбор только из списка сообщества, не из обнаружения).",
"bootstrap_pick_random_done": "Выбрано {count}: {names}",
"bootstrap_selected_nodes_heading": "Выбранные узлы",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "Перезапустите приложение, чтобы применить выбор хранилища.",
"failed": "Не удалось обновить расположение хранилища."
}
-}
\ No newline at end of file
+}
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index f2c57400..b4edf103 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -2805,7 +2805,9 @@
"channel_load": "信道负载",
"blackhole_label": "黑洞:{state}",
"blackhole_publishing": "发布中",
- "blackhole_inactive": "未激活"
+ "blackhole_inactive": "未激活",
+ "failed_refresh": "刷新 RNStatus 失败",
+ "reloading": "正在重新加载 RNS..."
},
"translator": {
"text_translation": "文本翻译",
@@ -3172,7 +3174,7 @@
"mode_change_later": "您可以随时从 Reticulum 设置中更改连接设置。",
"bootstrap_title": "选择引导节点",
"bootstrap_desc": "引导节点为发现提供起点。选择一个或多个,然后继续。",
- "bootstrap_pick_random_tcp": "随机选择 3 个 TCP 节点",
+ "bootstrap_pick_random_tcp": "随机选择 4 个 TCP 节点",
"bootstrap_pick_random_none": "没有可用的社区 TCP 预设。请刷新预设或手动选择(随机仅从社区列表选取,不含发现节点)。",
"bootstrap_pick_random_done": "已选择 {count} 个:{names}",
"bootstrap_selected_nodes_heading": "已选节点",
@@ -3470,4 +3472,4 @@
"restart_to_apply": "重启应用以应用存储选择。",
"failed": "无法更新存储位置。"
}
-}
\ No newline at end of file
+}
diff --git a/tests/backend/test_markdown_renderer.py b/tests/backend/test_markdown_renderer.py
index 2d77b351..3df8692c 100644
--- a/tests/backend/test_markdown_renderer.py
+++ b/tests/backend/test_markdown_renderer.py
@@ -49,6 +49,31 @@ class TestMarkdownRenderer(unittest.TestCase):
or "print('hello')" in rendered,
)
+ def test_inline_code_before_underscore_italic(self):
+ rendered = MarkdownRenderer.render("Uses `local_hops_delta` and ``rpc_key``.")
+ self.assertIn("local_hops_delta", rendered)
+ self.assertIn("rpc_key", rendered)
+ self.assertNotIn("<em>hops</em>", rendered)
+ self.assertNotIn("<em>key</em>", rendered)
+
+ def test_intraword_underscores_not_italic(self):
+ rendered = MarkdownRenderer.render("snake_case_identifier stays plain")
+ self.assertIn("snake_case_identifier stays plain", rendered)
+ self.assertNotIn("<em>", rendered)
+
+ def test_changelog_markdown_balanced_tags(self):
+ from pathlib import Path
+
+ changelog = Path(__file__).resolve().parents[2] / "CHANGELOG.md"
+ if not changelog.is_file():
+ self.skipTest("CHANGELOG.md not present")
+ rendered = MarkdownRenderer.render(changelog.read_text(encoding="utf-8"))
+ self.assertNotIn("[[CB", rendered)
+ self.assertNotIn("[[IC", rendered)
+ self.assertEqual(rendered.count("<em>"), rendered.count("</em>"))
+ self.assertEqual(rendered.count("<strong>"), rendered.count("</strong>"))
+ self.assertEqual(rendered.count("<code"), rendered.count("</code>"))
+
def test_lists(self):
md = "* Item 1\n* Item 2"
rendered = MarkdownRenderer.render(md)
diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
index 0fc63035..8c68d3c9 100644
--- a/tests/backend/test_plugin_manager.py
+++ b/tests/backend/test_plugin_manager.py
@@ -202,3 +202,28 @@ class TestPluginManagerInstall:
)
with pytest.raises(PermissionError):
manager.install_from_directory(os.path.abspath(source))
+
+ def test_bundled_reinstall_skips_unchanged_and_handles_readonly_tree(
+ self, tmp_path
+ ):
+ manager = _make_manager(tmp_path)
+ manager.install_bundled_examples()
+ plugin_id = "com.meshchatx.mcx-bugs"
+ record = manager._plugins[plugin_id]
+ first_hash = record.integrity_hash
+ install_path = record.install_path
+
+ for dirpath, dirnames, filenames in os.walk(install_path):
+ os.chmod(dirpath, 0o555)
+ for name in dirnames:
+ os.chmod(os.path.join(dirpath, name), 0o555)
+ for name in filenames:
+ os.chmod(os.path.join(dirpath, name), 0o444)
+
+ manager.install_bundled_examples()
+ assert manager._plugins[plugin_id].integrity_hash == first_hash
+
+ record.version = "0.0.0-test"
+ manager.install_bundled_examples()
+ assert manager._plugins[plugin_id].version != "0.0.0-test"
+ assert os.path.isdir(manager._plugins[plugin_id].install_path)
diff --git a/tests/backend/test_rns_lifecycle.py b/tests/backend/test_rns_lifecycle.py
index 2e4d6830..7dadb5c7 100644
--- a/tests/backend/test_rns_lifecycle.py
+++ b/tests/backend/test_rns_lifecycle.py
@@ -217,6 +217,82 @@ async def test_reload_reticulum(mock_rns, temp_dir):
assert mock_rns["Reticulum"]._Reticulum__instance is None
# Verify setup_identity was called again
app.setup_identity.assert_called()
+ # Hot reload must restore Transport._should_run after exit_handler.
+ assert mock_rns["Transport"]._should_run is True
+ assert app._network_ready is True
+ app.teardown_identity()
+
+
+def test_reset_transport_globals_for_reload_restores_should_run():
+ """Direct unit test against live Transport globals (restored in finally)."""
+ transport = RNS.Transport
+ saved = {
+ "_should_run": transport._should_run,
+ "path_table": dict(transport.path_table),
+ "link_table": dict(transport.link_table),
+ "announce_table": dict(transport.announce_table),
+ "control_destinations": list(transport.control_destinations),
+ "control_hashes": list(transport.control_hashes),
+ "mgmt_destinations": list(transport.mgmt_destinations),
+ "mgmt_hashes": list(transport.mgmt_hashes),
+ }
+ try:
+ transport._should_run = False
+ transport.path_table = {"stale": 1}
+ transport.link_table = {"stale": 2}
+ transport.announce_table = {"stale": 3}
+ transport.control_destinations = ["old"]
+ transport.control_hashes = [b"old"]
+ transport.mgmt_destinations = ["old"]
+ transport.mgmt_hashes = [b"old"]
+
+ ReticulumMeshChat._reset_transport_globals_for_reload()
+
+ assert transport._should_run is True
+ assert transport.path_table == {}
+ assert transport.link_table == {}
+ assert transport.announce_table == {}
+ assert transport.control_destinations == []
+ assert transport.control_hashes == []
+ assert transport.mgmt_destinations == []
+ assert transport.mgmt_hashes == []
+ finally:
+ for key, value in saved.items():
+ setattr(transport, key, value)
+
+
+def test_require_rns_tool_handler_returns_503_when_missing(mock_rns, temp_dir):
+ with (
+ patch("meshchatx.src.backend.identity_context.Database"),
+ patch("meshchatx.src.backend.identity_context.ConfigManager"),
+ patch("meshchatx.src.backend.identity_context.MessageHandler"),
+ patch("meshchatx.src.backend.identity_context.AnnounceManager"),
+ patch("meshchatx.src.backend.identity_context.ArchiverManager"),
+ patch("meshchatx.src.backend.identity_context.MapManager"),
+ patch("meshchatx.src.backend.identity_context.TelephoneManager"),
+ patch("meshchatx.src.backend.identity_context.VoicemailManager"),
+ patch("meshchatx.src.backend.identity_context.RingtoneManager"),
+ patch("meshchatx.src.backend.identity_context.RNCPHandler"),
+ patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
+ patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
+ patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
+ patch("LXMF.LXMRouter"),
+ ):
+ app = ReticulumMeshChat(
+ identity=mock_rns["id_instance"],
+ storage_dir=temp_dir,
+ reticulum_config_dir=temp_dir,
+ )
+ response = app._require_rns_tool_handler(None, "RNStatus")
+ assert response is not None
+ assert response.status == 503
+ body = json.loads(response.body)
+ assert "RNStatus" in body["message"]
+ assert "reloading" in body["message"].lower()
+
+ handler = MagicMock()
+ handler.reticulum = MagicMock()
+ assert app._require_rns_tool_handler(handler, "RNStatus") is None
app.teardown_identity()
diff --git a/tests/backend/test_rns_link_manager.py b/tests/backend/test_rns_link_manager.py
index d834b32d..925b39f5 100644
--- a/tests/backend/test_rns_link_manager.py
+++ b/tests/backend/test_rns_link_manager.py
@@ -79,6 +79,23 @@ def test_sweep_stale_links_and_orphan_counters():
assert ("orphan", dest_stale) not in rlm._link_failure_counts
+def test_clear_all_cached_links_tears_down_active():
+ dest = bytes.fromhex("33" * 16)
+ link = MagicMock()
+ link.status = rlm.RNS.Link.ACTIVE
+ with rlm._rns_links_lock:
+ rlm.rns_cached_links[("app.aspect", dest)] = link
+ rlm._rns_link_last_used[("app.aspect", dest)] = 1.0
+ rlm._link_failure_counts[("app.aspect", dest)] = 2
+ cleared = rlm.clear_all_cached_links()
+ assert cleared == 1
+ link.teardown.assert_called_once()
+ with rlm._rns_links_lock:
+ assert rlm.rns_cached_links == {}
+ assert rlm._rns_link_last_used == {}
+ assert rlm._link_failure_counts == {}
+
+
@pytest.mark.asyncio
async def test_open_link_reuses_cached_active():
dest = bytes.fromhex("bb" * 16)
diff --git a/tests/backend/test_self_check.py b/tests/backend/test_self_check.py
index 3c6b3c9c..a6064e66 100644
--- a/tests/backend/test_self_check.py
+++ b/tests/backend/test_self_check.py
@@ -101,6 +101,28 @@ def test_check_subprocess_spawn_ok():
assert self_check.check_subprocess_spawn()["status"] == "ok"
+def test_check_subprocess_spawn_frozen_uses_run_module(monkeypatch):
+ calls: list[list[str]] = []
+
+ class _Result:
+ returncode = 0
+ stdout = "meshchatx-self-check-probe spawn-ok\n"
+ stderr = ""
+
+ def _fake_run(cmd, **_kwargs):
+ calls.append(list(cmd))
+ return _Result()
+
+ monkeypatch.setattr(self_check, "_is_frozen_executable", lambda: True)
+ monkeypatch.setattr(self_check.subprocess, "run", _fake_run)
+ result = self_check.check_subprocess_spawn()
+ assert result["status"] == "ok", result["reason"]
+ assert calls
+ assert "-c" not in calls[0]
+ assert "--meshchatx-run-module" in calls[0]
+ assert "meshchatx.src.backend.self_check_probe" in calls[0]
+
+
def test_check_meshchatx_run_module_ok():
result = self_check.check_meshchatx_run_module()
assert result["status"] == "ok", result["reason"]
diff --git a/tests/frontend/BootLoadSmoothness.test.js b/tests/frontend/BootLoadSmoothness.test.js
index 698b4392..1f34045d 100644
--- a/tests/frontend/BootLoadSmoothness.test.js
+++ b/tests/frontend/BootLoadSmoothness.test.js
@@ -27,6 +27,8 @@ describe("boot and load smoothness", () => {
expect(html).not.toMatch(/body class="bg-gray-100"/);
expect(html).toContain("background-color: #f8fafc");
expect(html).toContain("background-color: #09090b");
+ expect(html).toContain("meshchatx_ui_theme");
+ expect(html).toContain("getPreferredUiTheme");
expect(html).toContain('id="meshchatx-boot-splash"');
expect(html).toContain('id="app"');
});
@@ -50,6 +52,8 @@ describe("boot and load smoothness", () => {
const app = readFileSync(resolve(ROOT, "meshchatx/src/frontend/components/App.vue"), "utf8");
expect(app).toContain('name="route-view-fade"');
expect(app).toContain("bg-sem-canvas");
+ expect(app).toContain("setUiTheme");
+ expect(app).toContain("meshchatx_ui_theme");
});
it("Android theme and WebView use meshchat canvas color", () => {
@@ -62,11 +66,14 @@ describe("boot and load smoothness", () => {
);
expect(colors).toContain("meshchat_canvas");
+ expect(colors).toContain("#FF09090B");
+ expect(colors).toContain("meshchat_canvas_light");
expect(colors).toContain("#FFF8FAFC");
expect(themes).toContain("android:windowBackground");
expect(layout).toContain("@color/meshchat_canvas");
- expect(activity).toContain("setBackgroundColor(canvasColor)");
- expect(activity).toContain("R.color.meshchat_canvas");
+ expect(activity).toContain("applyShellCanvasTheme");
+ expect(activity).toContain("setUiTheme");
+ expect(activity).toContain("setLocalNightMode");
});
it("injectMeshchatThemeVariables keeps light/dark canvas tokens aligned", () => {
diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index 49e1f25c..a9e5b634 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -48,6 +48,14 @@ describe("MarkdownRenderer.js", () => {
expect(result).toContain("code");
});
+ it("keeps snake_case intact inside inline and double-backtick code", () => {
+ const result = MarkdownRenderer.render("Uses `local_hops_delta` and ``rpc_key``.");
+ expect(result).toContain("local_hops_delta");
+ expect(result).toContain("rpc_key");
+ expect(result).not.toContain("<em>hops</em>");
+ expect(result).not.toContain("`local_hops_delta`");
+ });
+
it("keeps underscores intact in long https links", () => {
const url = "https://github.com/Quad4-Software/MeshChatX/src/branch/dev/docs/meshchatx_on_raspberry_pi.md";
const result = MarkdownRenderer.render(`visit ${url}`);
diff --git a/tests/frontend/RNStatusPage.test.js b/tests/frontend/RNStatusPage.test.js
index 583bbc88..7ce15b8e 100644
--- a/tests/frontend/RNStatusPage.test.js
+++ b/tests/frontend/RNStatusPage.test.js
@@ -1,8 +1,27 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import RNStatusPage from "@/components/rnstatus/RNStatusPage.vue";
+import ToastUtils from "@/js/ToastUtils";
+import WebSocketConnection from "@/js/WebSocketConnection";
import { mountToolsPageGlobals } from "./testI18n.js";
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ dismiss: vi.fn(),
+ },
+}));
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ },
+}));
+
describe("RNStatusPage.vue", () => {
let axiosMock;
@@ -60,6 +79,7 @@ describe("RNStatusPage.vue", () => {
expect(wrapper.text()).toContain("Blackhole: Publishing");
expect(wrapper.vm.blackholeEnabled).toBe(true);
expect(wrapper.text()).toContain("src1");
+ expect(WebSocketConnection.on).toHaveBeenCalledWith("message", expect.any(Function));
});
it("labels disabled blackhole as Inactive", async () => {
@@ -101,4 +121,34 @@ describe("RNStatusPage.vue", () => {
})
);
});
+
+ it("toasts on refresh failure", async () => {
+ axiosMock.get.mockRejectedValueOnce({
+ response: { data: { message: "RNS stack is reloading" }, status: 503 },
+ });
+ const wrapper = mountRNStatusPage();
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+ expect(ToastUtils.error).toHaveBeenCalled();
+ const msg = ToastUtils.error.mock.calls[0][0];
+ expect(msg).toContain("Failed to refresh RNStatus");
+ expect(msg).toContain("RNS stack is reloading");
+ });
+
+ it("disables refresh while RNS reload is in progress", async () => {
+ const wrapper = mountRNStatusPage();
+ await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false));
+ const callsBefore = axiosMock.get.mock.calls.length;
+
+ wrapper.vm.onWebsocketMessage({
+ type: "reticulum_reload_status",
+ in_progress: true,
+ message: "Reloading",
+ });
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.reloadingRns).toBe(true);
+ expect(wrapper.find("button").attributes("disabled")).toBeDefined();
+
+ await wrapper.vm.refreshStatus();
+ expect(axiosMock.get.mock.calls.length).toBe(callsBefore);
+ });
});
diff --git a/tests/frontend/TutorialModalMigration.test.js b/tests/frontend/TutorialModalMigration.test.js
index 56887cc2..9e42e204 100644
--- a/tests/frontend/TutorialModalMigration.test.js
+++ b/tests/frontend/TutorialModalMigration.test.js
@@ -578,7 +578,7 @@ describe("TutorialModal getting started migration", () => {
wrapper.unmount();
});
- it("hides footer Continue on connection step only", async () => {
+ it("hides footer Continue on connection and bootstrap steps", async () => {
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
const router = createRouter({
@@ -602,6 +602,10 @@ describe("TutorialModal getting started migration", () => {
wrapper.vm.currentStep = 4;
await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showFooterContinue).toBe(false);
+
+ wrapper.vm.currentStep = 5;
+ await wrapper.vm.$nextTick();
expect(wrapper.vm.showFooterContinue).toBe(true);
wrapper.unmount();
@@ -675,6 +679,60 @@ describe("TutorialModal getting started migration", () => {
wrapper.unmount();
});
+ it("confirmBootstraps reload failure keeps user on bootstrap step", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/community-interfaces") {
+ return Promise.resolve({
+ data: {
+ interfaces: [
+ {
+ name: "Test TCP",
+ type: "TCPClientInterface",
+ target_host: "1.2.3.4",
+ target_port: 4242,
+ },
+ ],
+ },
+ });
+ }
+ return discoveryApiHandlers({ show_choice: false })(url);
+ });
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/reticulum/interfaces/add") {
+ return Promise.resolve({ data: { message: "added" } });
+ }
+ if (url === "/api/v1/reticulum/reload") {
+ return Promise.reject({ response: { data: { error: "reload failed" } } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 4;
+ wrapper.vm.connectionMode = "discovery";
+ wrapper.vm.selectedBootstrapKeys = ["comm:Test TCP"];
+ await wrapper.vm.confirmBootstraps();
+ await flushPromises();
+
+ expect(wrapper.vm.currentStep).toBe(4);
+ expect(ToastUtils.error).toHaveBeenCalledWith(en.tutorial.failed_reload_rns);
+
+ wrapper.unmount();
+ });
+
it("finishTutorial blocks when pending interface reload fails", async () => {
const GlobalState = (await import("../../meshchatx/src/frontend/js/GlobalState.js")).default;
GlobalState.hasPendingInterfaceChanges = true;
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────